From e0666e0e500b4e278ee5f8c1529d6df4e35afbfc Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 18:11:15 -0300 Subject: [PATCH 001/695] tools: add sqlc tool --- go.mod | 91 ++++++++++++++--- go.sum | 253 +++++++++++++++++++++++++++++++++++++++-------- tools/Dockerfile | 3 +- tools/go.mod | 45 ++++++++- tools/go.sum | 138 ++++++++++++++++++++++++-- wtxmgr/go.mod | 2 +- wtxmgr/go.sum | 2 - 7 files changed, 463 insertions(+), 71 deletions(-) diff --git a/go.mod b/go.mod index 5ed9404034..4dabb045b0 100644 --- a/go.mod +++ b/go.mod @@ -17,48 +17,107 @@ require ( github.com/btcsuite/btcwallet/walletdb v1.6.0 github.com/btcsuite/btcwallet/wtxmgr v1.6.0 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/docker/go-connections v0.7.0 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang/protobuf v1.5.4 + github.com/jackc/pgx/v5 v5.10.0 github.com/jessevdk/go-flags v1.6.1 github.com/jrick/logrotate v1.1.2 + github.com/lib/pq v1.12.3 github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf github.com/lightninglabs/neutrino v0.18.0 github.com/lightninglabs/neutrino/cache v1.1.4 github.com/lightningnetwork/lnd/fn/v2 v2.0.8 github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.3-0.20260615022959-a067468f0f45 - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.41.0 - golang.org/x/net v0.43.0 - golang.org/x/sync v0.16.0 - golang.org/x/term v0.34.0 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 + golang.org/x/crypto v0.51.0 + golang.org/x/net v0.53.0 + golang.org/x/sync v0.20.0 + golang.org/x/term v0.43.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 + modernc.org/sqlite v1.53.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aead/siphash v1.0.1 // indirect github.com/btcsuite/btcd/v2transport v1.0.1 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/lru v1.1.3 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect github.com/kkdai/bstream v1.0.0 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lnd/queue v1.0.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.3.11 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) // If you change this please run `make lint` to see where else it needs to be diff --git a/go.sum b/go.sum index c2d93e042b..37de37cbb9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/btcsuite/btcd v0.26.0 h1:yntnSshlG3+H7dTwIOR4LTFXDPojVBsFORBNN5y5c/c= @@ -32,29 +40,81 @@ github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JG github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/lru v1.1.3 h1:w9EAbvGLyzm6jTjF83UKuqZEiUtJmvRhQDOCEIvSuE0= github.com/decred/dcrd/lru v1.1.3/go.mod h1:Tw0i0pJyiLEx/oZdHLe1Wdv/Y7EGzAX+sYftnmxBR4o= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0= @@ -63,10 +123,14 @@ github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyni github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= github.com/lightninglabs/neutrino v0.18.0 h1:UsyeU3twkCSAXxUssVg8vGXcHkWKzHZ3xQCej57t5t0= @@ -84,56 +148,165 @@ github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.3-0.20260615022959-a067468f0f45 h1:krvpxGTZJiDyvwU4Jr3GwR00lT4DKITDS95s28wQKU4= github.com/lightningnetwork/lnd/tlv v1.3.3-0.20260615022959-a067468f0f45/go.mod h1:oL5WIFd3ZoEwh3oH1xzizeUl6pq3DIhx9ljDvRdvI3Q= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/tools/Dockerfile b/tools/Dockerfile index bde0dbf984..4b154a4e70 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -2,7 +2,7 @@ FROM golang:1.25.11-alpine ENV GOFLAGS="-buildvcs=false" -RUN apk update && apk add --no-cache git +RUN apk update && apk add --no-cache git bash WORKDIR /build @@ -11,6 +11,7 @@ COPY tools/go.mod tools/go.sum ./ RUN go install -trimpath \ github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/sqlc-dev/sqlc/cmd/sqlc \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ diff --git a/tools/go.mod b/tools/go.mod index 306d9a6b4b..6108ec8473 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -5,14 +5,17 @@ go 1.25.11 tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/rinchsan/gosimports/cmd/gosimports + github.com/sqlc-dev/sqlc/cmd/sqlc ) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect + cel.dev/expr v0.24.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect dev.gaijin.team/go/golib v0.6.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/4meepo/tagalign v1.4.3 // indirect github.com/Abirdcfly/dupword v0.1.6 // indirect github.com/AlwxSin/noinlineerr v1.0.5 // indirect @@ -30,6 +33,7 @@ require ( github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect github.com/ashanbrown/makezero/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -52,12 +56,14 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -66,6 +72,7 @@ require ( github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.15 // indirect github.com/go-critic/go-critic v0.13.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -77,7 +84,7 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.0 // indirect @@ -89,7 +96,9 @@ require ( github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect @@ -101,8 +110,13 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect github.com/julz/importas v0.2.0 // indirect github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect @@ -135,11 +149,17 @@ require ( github.com/moricho/tparallel v0.3.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nakabonne/nestif v0.3.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.20.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect @@ -152,8 +172,10 @@ require ( github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rinchsan/gosimports v0.3.8 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect @@ -172,12 +194,15 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/viper v1.12.0 // indirect + github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.1 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect @@ -186,6 +211,8 @@ require ( github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.4.1 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect @@ -196,20 +223,32 @@ require ( go-simpler.org/sloglint v0.11.1 // indirect go.augendre.info/arangolint v0.2.0 // indirect go.augendre.info/fatcontext v0.8.1 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect mvdan.cc/gofumpt v0.8.0 // indirect mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect ) diff --git a/tools/go.sum b/tools/go.sum index 58b944c06d..945fcce995 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -2,6 +2,8 @@ 4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -41,6 +43,8 @@ dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88 dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c= @@ -86,12 +90,15 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs= github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA= github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY= github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -141,6 +148,8 @@ github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nW github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= @@ -156,6 +165,8 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -189,8 +200,12 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -249,8 +264,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= @@ -275,6 +290,8 @@ github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqt github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -301,6 +318,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= @@ -336,10 +355,20 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -433,6 +462,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= @@ -454,6 +485,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -497,11 +539,15 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rinchsan/gosimports v0.3.8 h1:X4Pb9yFf6teHvogorT04yK/0W2Df7eHO79biCcYrA4c= github.com/rinchsan/gosimports v0.3.8/go.mod h1:t0567k69sUHjLvJMPDsV31THZC+8UIbY1oL7NW+0I2c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -549,10 +595,14 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c= +github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -560,6 +610,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -571,6 +622,8 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -587,6 +640,10 @@ github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYR github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -621,12 +678,33 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -638,6 +716,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -648,8 +728,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo= @@ -835,6 +915,8 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -884,6 +966,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -935,6 +1019,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f h1:OiFuztEyBivVKDvguQJYWq1yDcfAHIID/FVrPR4oiI0= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f/go.mod h1:kprOiu9Tr0JYyD6DORrc4Hfyk3RFXqkQ3ctHEum3ZbM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -947,6 +1035,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -959,8 +1049,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -970,14 +1061,19 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -989,6 +1085,32 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= diff --git a/wtxmgr/go.mod b/wtxmgr/go.mod index e166192aa8..d185b94a62 100644 --- a/wtxmgr/go.mod +++ b/wtxmgr/go.mod @@ -7,7 +7,6 @@ require ( github.com/btcsuite/btcd/chainhash/v2 v2.0.0 github.com/btcsuite/btcd/wire/v2 v2.0.0 github.com/btcsuite/btclog v1.0.0 - github.com/btcsuite/btcwallet v0.16.18 github.com/btcsuite/btcwallet/walletdb v1.6.0 github.com/lightningnetwork/lnd/clock v1.0.1 github.com/stretchr/testify v1.10.0 @@ -24,6 +23,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect go.etcd.io/bbolt v1.3.11 // indirect golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/wtxmgr/go.sum b/wtxmgr/go.sum index 4d9103c419..520c948859 100644 --- a/wtxmgr/go.sum +++ b/wtxmgr/go.sum @@ -16,8 +16,6 @@ github.com/btcsuite/btcd/wire/v2 v2.0.0 h1:mYSKzZZ0a1sK+aMhXzfDSVsSzRkWkU3x2U04T github.com/btcsuite/btcd/wire/v2 v2.0.0/go.mod h1:bGxkPkk8IiDvUo1D96wE03llBIk7p2MdWYRyAQwLmqM= github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns= github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ= -github.com/btcsuite/btcwallet v0.16.18 h1:6h0kMxij4igPu35jOPAWZbn22ceOC4me4L3jj8Za6Zk= -github.com/btcsuite/btcwallet v0.16.18/go.mod h1:4TTru0cgIPbCZpY4aRfAVwX87zrQw4GXM8MH6+A5xZw= github.com/btcsuite/btcwallet/walletdb v1.6.0 h1:Yund5XbdqFxNW7+R2Sxs02bMC5fMrmORj4GN8MV55no= github.com/btcsuite/btcwallet/walletdb v1.6.0/go.mod h1:q9xif0Csp52GVb3l252BbHCuyiCnuEbrPWu/HAsvaYc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From b6fbbb603fb430843c16e96f12b3911fa94a3535 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:50:56 -0300 Subject: [PATCH 002/695] internal: add sqlc config --- internal/db/sqldb/sqlc/sqlc.yaml | 28 ++++++++++ internal/db/sqldb/sqlc/sqlc_generate.sh | 74 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 internal/db/sqldb/sqlc/sqlc.yaml create mode 100755 internal/db/sqldb/sqlc/sqlc_generate.sh diff --git a/internal/db/sqldb/sqlc/sqlc.yaml b/internal/db/sqldb/sqlc/sqlc.yaml new file mode 100644 index 0000000000..738c4cc336 --- /dev/null +++ b/internal/db/sqldb/sqlc/sqlc.yaml @@ -0,0 +1,28 @@ +# sqlc configuration file for generating type-safe Go code from SQL. +# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html +version: "2" +sql: + - engine: "postgresql" + schema: "../migrations" + queries: "queries" + gen: + go: + out: . + package: sqlc + + # This is the driver package that sqlc will use in the generated code. + # The default value already is `database/sql`, but we are being + # explicit here that we want multiple database drivers to be supported. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they’re usable from other packages. + emit_exported_queries: true + + # Use prepared statements to reuse plans and cut parse overhead. + # The pgx driver already does this internally, but for other drivers + # like sqlite this can provide a performance boost. + emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc_generate.sh b/internal/db/sqldb/sqlc/sqlc_generate.sh new file mode 100755 index 0000000000..1bfeb0c69c --- /dev/null +++ b/internal/db/sqldb/sqlc/sqlc_generate.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -e + +# Directory of the script file, independent of where it's called from. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# restore_files restore original schema files. +function restore_files() { + echo "Restoring SQLite bigint patch..." + for file in "$DIR"/../migrations/*.up.sql.original; do + mv "$file" "${file%.original}" + done +} + +# Set trap to call restore_files on script exit. This makes sure the old files +# are always restored. +trap restore_files EXIT + +# SQLite requires `INTEGER PRIMARY KEY` for autoincrement, but SQLC emits Go +# int32 for that type. We temporarily patch schemas to `BIGINT PRIMARY KEY` so +# SQLC generates int64, then restore the originals via the trap. When migrating +# in postgres, the type should be replaced to `BIGINT` to support int64. +echo "Applying SQLite bigint patch..." +for file in "$DIR"/../migrations/*.up.sql; do + echo "Patching $file" + sed -i.original -E 's/INTEGER PRIMARY KEY/BIGINT PRIMARY KEY/g' "$file" +done + +echo "Generating sql models and queries in go..." + +# Generate code via sqlc +sqlc generate -f "$DIR"/sqlc.yaml + +# Because we're using the Postgres dialect of SQLC, we can't use sqlc.slice() +# normally, because SQLC just thinks it can pass the Golang slice directly to +# the database driver. So it doesn't put the /*SLICE:*/ workaround +# comment into the actual SQL query. But we add the comment ourselves and now +# just need to replace the '$X/*SLICE:*/' placeholders with the +# actual placeholder that's going to be replaced by the SQLC generated code. +echo "Applying sqlc.slice() workaround..." +for file in "$DIR"/*.sql.go; do + echo "Patching $file" + + # This sed invocation transforms SQLC placeholders for slices. SQLC writes + # placeholders such as '$1/*SLICE:ids*/' where '$1' is a numeric placeholder + # and 'ids' is the slice name. + # The search pattern looks for a dollar sign and number followed by the + # '/*SLICE:name*/' comment. + # In the pattern: + # \$([0-9]+) captures the number; we ignore this capture. + # /\*SLICE: matches the literal comment start. + # ([a-zA-Z_][a-zA-Z0-9_]*) captures the slice name. + # \*/ matches the end of the comment. + # The replacement rebuilds the comment using the captured name and appends a + # '?' so that makeQueryParams can replace it. + # We pick '#' as the sed delimiter to avoid escaping the slashes in the + # comment markers. + sed -i.original -E "s#\$([0-9]+)/\*SLICE:([a-zA-Z_][a-zA-Z0-9_]*)\*/#/*SLICE:\2*/?#g" "$file" + + # Next we replace the code that uses strings.Repeat to build a list of '?' + # markers for an IN clause with a call to makeQueryParams. + # The search pattern `strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]` + # matches expressions like `strings.Repeat(",?", len(arg.Scids))[1:]`. + # strings\.Repeat\(",\?", len\( matches the literal prefix. + # ([^)]+) captures the expression inside len(), such as arg.Scids. + # \)\)\[1:\] matches the closing brackets and slice notation. + # The replacement `makeQueryParams(len(queryParams), len(\1))` constructs a + # call to our helper using the captured argument and the current parameter + # count. + sed -i.original -E 's/strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]/makeQueryParams(len(queryParams), len(\1))/g' "$file" + + rm "$file.original" +done From 6bdb0b93fc82223c5ef40bf4750e5761a4895483 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:52:19 -0300 Subject: [PATCH 003/695] internal: add dummy migration tables --- internal/db/sqldb/migrations/000001__dummy.down.sql | 1 + internal/db/sqldb/migrations/000001__dummy.up.sql | 5 +++++ internal/db/sqldb/sqlc/queries/dummy.sql | 8 ++++++++ 3 files changed, 14 insertions(+) create mode 100644 internal/db/sqldb/migrations/000001__dummy.down.sql create mode 100644 internal/db/sqldb/migrations/000001__dummy.up.sql create mode 100644 internal/db/sqldb/sqlc/queries/dummy.sql diff --git a/internal/db/sqldb/migrations/000001__dummy.down.sql b/internal/db/sqldb/migrations/000001__dummy.down.sql new file mode 100644 index 0000000000..eb3025b868 --- /dev/null +++ b/internal/db/sqldb/migrations/000001__dummy.down.sql @@ -0,0 +1 @@ +DROP TABLE "dummy"; diff --git a/internal/db/sqldb/migrations/000001__dummy.up.sql b/internal/db/sqldb/migrations/000001__dummy.up.sql new file mode 100644 index 0000000000..b9d37859d0 --- /dev/null +++ b/internal/db/sqldb/migrations/000001__dummy.up.sql @@ -0,0 +1,5 @@ +-- Dummy migration to provide initial SQLC material. +-- Should be removed once we have real migrations. +CREATE TABLE "dummy" ( + "id" INTEGER PRIMARY KEY +); diff --git a/internal/db/sqldb/sqlc/queries/dummy.sql b/internal/db/sqldb/sqlc/queries/dummy.sql new file mode 100644 index 0000000000..db969d5e0f --- /dev/null +++ b/internal/db/sqldb/sqlc/queries/dummy.sql @@ -0,0 +1,8 @@ +-- name: GetDummyById :one +SELECT id FROM dummy WHERE id = $1; + +-- name: InsertDummy :exec +INSERT INTO dummy (id) VALUES ($1); + +-- name: DeleteDummy :exec +DELETE FROM dummy WHERE id = $1; From a72584da266061a8d2df2b3b4d99ed5416f57cd7 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:03 -0300 Subject: [PATCH 004/695] internal: first sqlc run --- internal/db/sqldb/sqlc/db.go | 108 ++++++++++++++++++++++++++++ internal/db/sqldb/sqlc/dummy.sql.go | 38 ++++++++++ internal/db/sqldb/sqlc/models.go | 9 +++ internal/db/sqldb/sqlc/querier.go | 17 +++++ 4 files changed, 172 insertions(+) create mode 100644 internal/db/sqldb/sqlc/db.go create mode 100644 internal/db/sqldb/sqlc/dummy.sql.go create mode 100644 internal/db/sqldb/sqlc/models.go create mode 100644 internal/db/sqldb/sqlc/querier.go diff --git a/internal/db/sqldb/sqlc/db.go b/internal/db/sqldb/sqlc/db.go new file mode 100644 index 0000000000..57245f3902 --- /dev/null +++ b/internal/db/sqldb/sqlc/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteDummyStmt, err = db.PrepareContext(ctx, DeleteDummy); err != nil { + return nil, fmt.Errorf("error preparing query DeleteDummy: %w", err) + } + if q.getDummyByIdStmt, err = db.PrepareContext(ctx, GetDummyById); err != nil { + return nil, fmt.Errorf("error preparing query GetDummyById: %w", err) + } + if q.insertDummyStmt, err = db.PrepareContext(ctx, InsertDummy); err != nil { + return nil, fmt.Errorf("error preparing query InsertDummy: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteDummyStmt != nil { + if cerr := q.deleteDummyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteDummyStmt: %w", cerr) + } + } + if q.getDummyByIdStmt != nil { + if cerr := q.getDummyByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getDummyByIdStmt: %w", cerr) + } + } + if q.insertDummyStmt != nil { + if cerr := q.insertDummyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertDummyStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteDummyStmt *sql.Stmt + getDummyByIdStmt *sql.Stmt + insertDummyStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteDummyStmt: q.deleteDummyStmt, + getDummyByIdStmt: q.getDummyByIdStmt, + insertDummyStmt: q.insertDummyStmt, + } +} diff --git a/internal/db/sqldb/sqlc/dummy.sql.go b/internal/db/sqldb/sqlc/dummy.sql.go new file mode 100644 index 0000000000..c8c5fb1bfb --- /dev/null +++ b/internal/db/sqldb/sqlc/dummy.sql.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: dummy.sql + +package sqlc + +import ( + "context" +) + +const DeleteDummy = `-- name: DeleteDummy :exec +DELETE FROM dummy WHERE id = $1 +` + +func (q *Queries) DeleteDummy(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.deleteDummyStmt, DeleteDummy, id) + return err +} + +const GetDummyById = `-- name: GetDummyById :one +SELECT id FROM dummy WHERE id = $1 +` + +func (q *Queries) GetDummyById(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getDummyByIdStmt, GetDummyById, id) + err := row.Scan(&id) + return id, err +} + +const InsertDummy = `-- name: InsertDummy :exec +INSERT INTO dummy (id) VALUES ($1) +` + +func (q *Queries) InsertDummy(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.insertDummyStmt, InsertDummy, id) + return err +} diff --git a/internal/db/sqldb/sqlc/models.go b/internal/db/sqldb/sqlc/models.go new file mode 100644 index 0000000000..3712e68fff --- /dev/null +++ b/internal/db/sqldb/sqlc/models.go @@ -0,0 +1,9 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +type Dummy struct { + ID int64 +} diff --git a/internal/db/sqldb/sqlc/querier.go b/internal/db/sqldb/sqlc/querier.go new file mode 100644 index 0000000000..5feaa3b077 --- /dev/null +++ b/internal/db/sqldb/sqlc/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" +) + +type Querier interface { + DeleteDummy(ctx context.Context, id int64) error + GetDummyById(ctx context.Context, id int64) (int64, error) + InsertDummy(ctx context.Context, id int64) error +} + +var _ Querier = (*Queries)(nil) From 66cc4be3dcec0d37514a5f4f753a3732f20388e9 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:38 -0300 Subject: [PATCH 005/695] Makefile: add sqlc and sqlc-check commands --- Makefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Makefile b/Makefile index def43ca079..5a27468d0d 100644 --- a/Makefile +++ b/Makefile @@ -159,6 +159,16 @@ tidy-module: tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi +#? sqlc: Generate sql models and queries in Go +sqlc: + @$(call print, "Generating sql models and queries in Go") + $(DOCKER_TOOLS) internal/db/sqldb/sqlc/sqlc_generate.sh + +#? sqlc-check: Make sure sql models and queries are up to date +sqlc-check: sqlc + @$(call print, "Verifying sql code generation.") + if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi + .PHONY: all \ default \ build \ @@ -172,6 +182,8 @@ tidy-module-check: tidy-module fmt-check \ tidy-module \ tidy-module-check \ + sqlc \ + sqlc-check \ rpc-format \ lint \ lint-config-check \ From bd468f6cdd5bbcbb85aa96e818d67be23ff6fa28 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:51 -0300 Subject: [PATCH 006/695] CI: add SQL models check --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7591492088..bbdd5257c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,9 @@ jobs: - name: Check RPC format run: make rpc-check + - name: Check SQL models + run: make sqlc-check + - name: compile code run: go install -v ./... From c2e5c267f41ad6e0e67fde7d57331b1522827180 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 18:36:21 +0000 Subject: [PATCH 007/695] internal: remove non-functional sql files --- .../sqldb/migrations/000001__dummy.down.sql | 1 - .../db/sqldb/migrations/000001__dummy.up.sql | 5 - internal/db/sqldb/sqlc/db.go | 108 ------------------ internal/db/sqldb/sqlc/dummy.sql.go | 38 ------ internal/db/sqldb/sqlc/models.go | 9 -- internal/db/sqldb/sqlc/querier.go | 17 --- internal/db/sqldb/sqlc/queries/dummy.sql | 8 -- 7 files changed, 186 deletions(-) delete mode 100644 internal/db/sqldb/migrations/000001__dummy.down.sql delete mode 100644 internal/db/sqldb/migrations/000001__dummy.up.sql delete mode 100644 internal/db/sqldb/sqlc/db.go delete mode 100644 internal/db/sqldb/sqlc/dummy.sql.go delete mode 100644 internal/db/sqldb/sqlc/models.go delete mode 100644 internal/db/sqldb/sqlc/querier.go delete mode 100644 internal/db/sqldb/sqlc/queries/dummy.sql diff --git a/internal/db/sqldb/migrations/000001__dummy.down.sql b/internal/db/sqldb/migrations/000001__dummy.down.sql deleted file mode 100644 index eb3025b868..0000000000 --- a/internal/db/sqldb/migrations/000001__dummy.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "dummy"; diff --git a/internal/db/sqldb/migrations/000001__dummy.up.sql b/internal/db/sqldb/migrations/000001__dummy.up.sql deleted file mode 100644 index b9d37859d0..0000000000 --- a/internal/db/sqldb/migrations/000001__dummy.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Dummy migration to provide initial SQLC material. --- Should be removed once we have real migrations. -CREATE TABLE "dummy" ( - "id" INTEGER PRIMARY KEY -); diff --git a/internal/db/sqldb/sqlc/db.go b/internal/db/sqldb/sqlc/db.go deleted file mode 100644 index 57245f3902..0000000000 --- a/internal/db/sqldb/sqlc/db.go +++ /dev/null @@ -1,108 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -import ( - "context" - "database/sql" - "fmt" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -func Prepare(ctx context.Context, db DBTX) (*Queries, error) { - q := Queries{db: db} - var err error - if q.deleteDummyStmt, err = db.PrepareContext(ctx, DeleteDummy); err != nil { - return nil, fmt.Errorf("error preparing query DeleteDummy: %w", err) - } - if q.getDummyByIdStmt, err = db.PrepareContext(ctx, GetDummyById); err != nil { - return nil, fmt.Errorf("error preparing query GetDummyById: %w", err) - } - if q.insertDummyStmt, err = db.PrepareContext(ctx, InsertDummy); err != nil { - return nil, fmt.Errorf("error preparing query InsertDummy: %w", err) - } - return &q, nil -} - -func (q *Queries) Close() error { - var err error - if q.deleteDummyStmt != nil { - if cerr := q.deleteDummyStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing deleteDummyStmt: %w", cerr) - } - } - if q.getDummyByIdStmt != nil { - if cerr := q.getDummyByIdStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getDummyByIdStmt: %w", cerr) - } - } - if q.insertDummyStmt != nil { - if cerr := q.insertDummyStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing insertDummyStmt: %w", cerr) - } - } - return err -} - -func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) - case stmt != nil: - return stmt.ExecContext(ctx, args...) - default: - return q.db.ExecContext(ctx, query, args...) - } -} - -func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) - case stmt != nil: - return stmt.QueryContext(ctx, args...) - default: - return q.db.QueryContext(ctx, query, args...) - } -} - -func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) - case stmt != nil: - return stmt.QueryRowContext(ctx, args...) - default: - return q.db.QueryRowContext(ctx, query, args...) - } -} - -type Queries struct { - db DBTX - tx *sql.Tx - deleteDummyStmt *sql.Stmt - getDummyByIdStmt *sql.Stmt - insertDummyStmt *sql.Stmt -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - tx: tx, - deleteDummyStmt: q.deleteDummyStmt, - getDummyByIdStmt: q.getDummyByIdStmt, - insertDummyStmt: q.insertDummyStmt, - } -} diff --git a/internal/db/sqldb/sqlc/dummy.sql.go b/internal/db/sqldb/sqlc/dummy.sql.go deleted file mode 100644 index c8c5fb1bfb..0000000000 --- a/internal/db/sqldb/sqlc/dummy.sql.go +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: dummy.sql - -package sqlc - -import ( - "context" -) - -const DeleteDummy = `-- name: DeleteDummy :exec -DELETE FROM dummy WHERE id = $1 -` - -func (q *Queries) DeleteDummy(ctx context.Context, id int64) error { - _, err := q.exec(ctx, q.deleteDummyStmt, DeleteDummy, id) - return err -} - -const GetDummyById = `-- name: GetDummyById :one -SELECT id FROM dummy WHERE id = $1 -` - -func (q *Queries) GetDummyById(ctx context.Context, id int64) (int64, error) { - row := q.queryRow(ctx, q.getDummyByIdStmt, GetDummyById, id) - err := row.Scan(&id) - return id, err -} - -const InsertDummy = `-- name: InsertDummy :exec -INSERT INTO dummy (id) VALUES ($1) -` - -func (q *Queries) InsertDummy(ctx context.Context, id int64) error { - _, err := q.exec(ctx, q.insertDummyStmt, InsertDummy, id) - return err -} diff --git a/internal/db/sqldb/sqlc/models.go b/internal/db/sqldb/sqlc/models.go deleted file mode 100644 index 3712e68fff..0000000000 --- a/internal/db/sqldb/sqlc/models.go +++ /dev/null @@ -1,9 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -type Dummy struct { - ID int64 -} diff --git a/internal/db/sqldb/sqlc/querier.go b/internal/db/sqldb/sqlc/querier.go deleted file mode 100644 index 5feaa3b077..0000000000 --- a/internal/db/sqldb/sqlc/querier.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -import ( - "context" -) - -type Querier interface { - DeleteDummy(ctx context.Context, id int64) error - GetDummyById(ctx context.Context, id int64) (int64, error) - InsertDummy(ctx context.Context, id int64) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/db/sqldb/sqlc/queries/dummy.sql b/internal/db/sqldb/sqlc/queries/dummy.sql deleted file mode 100644 index db969d5e0f..0000000000 --- a/internal/db/sqldb/sqlc/queries/dummy.sql +++ /dev/null @@ -1,8 +0,0 @@ --- name: GetDummyById :one -SELECT id FROM dummy WHERE id = $1; - --- name: InsertDummy :exec -INSERT INTO dummy (id) VALUES ($1); - --- name: DeleteDummy :exec -DELETE FROM dummy WHERE id = $1; From ab893f0f7288ca442fdce66fb62fdab295cc2258 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 18:37:47 +0000 Subject: [PATCH 008/695] internal+Makefile: simplify the SQL queries and migrations management --- Makefile | 4 +- internal/db/sqlc/sqlc.yaml | 45 +++++++++++++++ internal/db/sqldb/sqlc/sqlc.yaml | 28 ---------- internal/db/sqldb/sqlc/sqlc_generate.sh | 74 ------------------------- 4 files changed, 47 insertions(+), 104 deletions(-) create mode 100644 internal/db/sqlc/sqlc.yaml delete mode 100644 internal/db/sqldb/sqlc/sqlc.yaml delete mode 100755 internal/db/sqldb/sqlc/sqlc_generate.sh diff --git a/Makefile b/Makefile index 5a27468d0d..aaf745e416 100644 --- a/Makefile +++ b/Makefile @@ -160,9 +160,9 @@ tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi #? sqlc: Generate sql models and queries in Go -sqlc: +sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) internal/db/sqldb/sqlc/sqlc_generate.sh + $(DOCKER_TOOLS) sqlc generate -f internal/db/sqlc/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/internal/db/sqlc/sqlc.yaml b/internal/db/sqlc/sqlc.yaml new file mode 100644 index 0000000000..158d518a3b --- /dev/null +++ b/internal/db/sqlc/sqlc.yaml @@ -0,0 +1,45 @@ +# sqlc configuration file for generating type-safe Go code from SQL. +# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html +version: "2" +sql: + - engine: "postgresql" + schema: "../migrations/postgres" + queries: "../queries/postgres" + gen: + go: + out: "postgres" + package: "sqlcpg" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true + + - engine: "sqlite" + schema: "../migrations/sqlite" + queries: "../queries/sqlite" + gen: + go: + out: "sqlite" + package: "sqlcsqlite" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc.yaml b/internal/db/sqldb/sqlc/sqlc.yaml deleted file mode 100644 index 738c4cc336..0000000000 --- a/internal/db/sqldb/sqlc/sqlc.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# sqlc configuration file for generating type-safe Go code from SQL. -# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html -version: "2" -sql: - - engine: "postgresql" - schema: "../migrations" - queries: "queries" - gen: - go: - out: . - package: sqlc - - # This is the driver package that sqlc will use in the generated code. - # The default value already is `database/sql`, but we are being - # explicit here that we want multiple database drivers to be supported. - sql_package: database/sql - - # Generate a `Querier` interface of all query methods. It's useful for - # mocking in tests. - emit_interface: true - - # Export generated SQL statements so they’re usable from other packages. - emit_exported_queries: true - - # Use prepared statements to reuse plans and cut parse overhead. - # The pgx driver already does this internally, but for other drivers - # like sqlite this can provide a performance boost. - emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc_generate.sh b/internal/db/sqldb/sqlc/sqlc_generate.sh deleted file mode 100755 index 1bfeb0c69c..0000000000 --- a/internal/db/sqldb/sqlc/sqlc_generate.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -set -e - -# Directory of the script file, independent of where it's called from. -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# restore_files restore original schema files. -function restore_files() { - echo "Restoring SQLite bigint patch..." - for file in "$DIR"/../migrations/*.up.sql.original; do - mv "$file" "${file%.original}" - done -} - -# Set trap to call restore_files on script exit. This makes sure the old files -# are always restored. -trap restore_files EXIT - -# SQLite requires `INTEGER PRIMARY KEY` for autoincrement, but SQLC emits Go -# int32 for that type. We temporarily patch schemas to `BIGINT PRIMARY KEY` so -# SQLC generates int64, then restore the originals via the trap. When migrating -# in postgres, the type should be replaced to `BIGINT` to support int64. -echo "Applying SQLite bigint patch..." -for file in "$DIR"/../migrations/*.up.sql; do - echo "Patching $file" - sed -i.original -E 's/INTEGER PRIMARY KEY/BIGINT PRIMARY KEY/g' "$file" -done - -echo "Generating sql models and queries in go..." - -# Generate code via sqlc -sqlc generate -f "$DIR"/sqlc.yaml - -# Because we're using the Postgres dialect of SQLC, we can't use sqlc.slice() -# normally, because SQLC just thinks it can pass the Golang slice directly to -# the database driver. So it doesn't put the /*SLICE:*/ workaround -# comment into the actual SQL query. But we add the comment ourselves and now -# just need to replace the '$X/*SLICE:*/' placeholders with the -# actual placeholder that's going to be replaced by the SQLC generated code. -echo "Applying sqlc.slice() workaround..." -for file in "$DIR"/*.sql.go; do - echo "Patching $file" - - # This sed invocation transforms SQLC placeholders for slices. SQLC writes - # placeholders such as '$1/*SLICE:ids*/' where '$1' is a numeric placeholder - # and 'ids' is the slice name. - # The search pattern looks for a dollar sign and number followed by the - # '/*SLICE:name*/' comment. - # In the pattern: - # \$([0-9]+) captures the number; we ignore this capture. - # /\*SLICE: matches the literal comment start. - # ([a-zA-Z_][a-zA-Z0-9_]*) captures the slice name. - # \*/ matches the end of the comment. - # The replacement rebuilds the comment using the captured name and appends a - # '?' so that makeQueryParams can replace it. - # We pick '#' as the sed delimiter to avoid escaping the slashes in the - # comment markers. - sed -i.original -E "s#\$([0-9]+)/\*SLICE:([a-zA-Z_][a-zA-Z0-9_]*)\*/#/*SLICE:\2*/?#g" "$file" - - # Next we replace the code that uses strings.Repeat to build a list of '?' - # markers for an IN clause with a call to makeQueryParams. - # The search pattern `strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]` - # matches expressions like `strings.Repeat(",?", len(arg.Scids))[1:]`. - # strings\.Repeat\(",\?", len\( matches the literal prefix. - # ([^)]+) captures the expression inside len(), such as arg.Scids. - # \)\)\[1:\] matches the closing brackets and slice notation. - # The replacement `makeQueryParams(len(queryParams), len(\1))` constructs a - # call to our helper using the captured argument and the current parameter - # count. - sed -i.original -E 's/strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]/makeQueryParams(len(queryParams), len(\1))/g' "$file" - - rm "$file.original" -done From f6e0258e080337e7d598b2f1a328c9a3b8b3a9c5 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 19:03:39 +0000 Subject: [PATCH 009/695] internal/db: design `blocks` table schema --- .../postgres/000001_blocks.down.sql | 3 + .../migrations/postgres/000001_blocks.up.sql | 17 +++ .../migrations/sqlite/000001_blocks.down.sql | 3 + .../db/migrations/sqlite/000001_blocks.up.sql | 17 +++ internal/db/queries/postgres/blocks.sql | 12 ++ internal/db/queries/sqlite/blocks.sql | 12 ++ internal/db/sqlc/postgres/blocks.sql.go | 49 ++++++++ internal/db/sqlc/postgres/db.go | 108 ++++++++++++++++++ internal/db/sqlc/postgres/models.go | 11 ++ internal/db/sqlc/postgres/querier.go | 17 +++ internal/db/sqlc/sqlite/blocks.sql.go | 49 ++++++++ internal/db/sqlc/sqlite/db.go | 108 ++++++++++++++++++ internal/db/sqlc/sqlite/models.go | 11 ++ internal/db/sqlc/sqlite/querier.go | 17 +++ 14 files changed, 434 insertions(+) create mode 100644 internal/db/migrations/postgres/000001_blocks.down.sql create mode 100644 internal/db/migrations/postgres/000001_blocks.up.sql create mode 100644 internal/db/migrations/sqlite/000001_blocks.down.sql create mode 100644 internal/db/migrations/sqlite/000001_blocks.up.sql create mode 100644 internal/db/queries/postgres/blocks.sql create mode 100644 internal/db/queries/sqlite/blocks.sql create mode 100644 internal/db/sqlc/postgres/blocks.sql.go create mode 100644 internal/db/sqlc/postgres/db.go create mode 100644 internal/db/sqlc/postgres/models.go create mode 100644 internal/db/sqlc/postgres/querier.go create mode 100644 internal/db/sqlc/sqlite/blocks.sql.go create mode 100644 internal/db/sqlc/sqlite/db.go create mode 100644 internal/db/sqlc/sqlite/models.go create mode 100644 internal/db/sqlc/sqlite/querier.go diff --git a/internal/db/migrations/postgres/000001_blocks.down.sql b/internal/db/migrations/postgres/000001_blocks.down.sql new file mode 100644 index 0000000000..1dec60e3be --- /dev/null +++ b/internal/db/migrations/postgres/000001_blocks.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS blocks; diff --git a/internal/db/migrations/postgres/000001_blocks.up.sql b/internal/db/migrations/postgres/000001_blocks.up.sql new file mode 100644 index 0000000000..f85ce44559 --- /dev/null +++ b/internal/db/migrations/postgres/000001_blocks.up.sql @@ -0,0 +1,17 @@ +-- Block metadata - tracks blocks containing wallet transactions +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE blocks ( + -- Natural key - blockchain height (genesis = 0). + -- The height value itself is immutable (100 is always 100), + -- but during reorgs the block at this height can be replaced + -- (DELETE old row, INSERT new row with same height, different hash). + block_height INTEGER PRIMARY KEY CHECK (block_height >= 0), + + -- Block header hash - unique identifier for this specific block (32 bytes). + header_hash BYTEA NOT NULL UNIQUE CHECK (length(header_hash) = 32), + + -- Unix timestamp - when the block was mined (seconds since epoch). + timestamp BIGINT NOT NULL CHECK (timestamp >= 0) +); diff --git a/internal/db/migrations/sqlite/000001_blocks.down.sql b/internal/db/migrations/sqlite/000001_blocks.down.sql new file mode 100644 index 0000000000..1dec60e3be --- /dev/null +++ b/internal/db/migrations/sqlite/000001_blocks.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS blocks; diff --git a/internal/db/migrations/sqlite/000001_blocks.up.sql b/internal/db/migrations/sqlite/000001_blocks.up.sql new file mode 100644 index 0000000000..26a6397681 --- /dev/null +++ b/internal/db/migrations/sqlite/000001_blocks.up.sql @@ -0,0 +1,17 @@ +-- Block metadata - tracks blocks containing wallet transactions +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE blocks ( + -- Natural key - blockchain height (genesis = 0). + -- The height value itself is immutable (100 is always 100), + -- but during reorgs the block at this height can be replaced + -- (DELETE old row, INSERT new row with same height, different hash). + block_height INTEGER PRIMARY KEY CHECK (block_height >= 0), + + -- Block header hash - unique identifier for this specific block (32 bytes). + header_hash BLOB NOT NULL UNIQUE CHECK (length(header_hash) = 32), + + -- Unix timestamp - when the block was mined (seconds since epoch). + timestamp INTEGER NOT NULL CHECK (timestamp >= 0) +); diff --git a/internal/db/queries/postgres/blocks.sql b/internal/db/queries/postgres/blocks.sql new file mode 100644 index 0000000000..dc9884ba32 --- /dev/null +++ b/internal/db/queries/postgres/blocks.sql @@ -0,0 +1,12 @@ +-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = $1; + +-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES ($1, $2, $3); + +-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = $1; diff --git a/internal/db/queries/sqlite/blocks.sql b/internal/db/queries/sqlite/blocks.sql new file mode 100644 index 0000000000..f37744df57 --- /dev/null +++ b/internal/db/queries/sqlite/blocks.sql @@ -0,0 +1,12 @@ +-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = ?; + +-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES (?, ?, ?); + +-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = ?; diff --git a/internal/db/sqlc/postgres/blocks.sql.go b/internal/db/sqlc/postgres/blocks.sql.go new file mode 100644 index 0000000000..8ca86ef812 --- /dev/null +++ b/internal/db/sqlc/postgres/blocks.sql.go @@ -0,0 +1,49 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: blocks.sql + +package sqlcpg + +import ( + "context" +) + +const DeleteBlock = `-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = $1 +` + +func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int32) error { + _, err := q.exec(ctx, q.deleteBlockStmt, DeleteBlock, blockHeight) + return err +} + +const GetBlockByHeight = `-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = $1 +` + +func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) { + row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + return i, err +} + +const InsertBlock = `-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES ($1, $2, $3) +` + +type InsertBlockParams struct { + BlockHeight int32 + HeaderHash []byte + Timestamp int64 +} + +func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + return err +} diff --git a/internal/db/sqlc/postgres/db.go b/internal/db/sqlc/postgres/db.go new file mode 100644 index 0000000000..852f9a65f3 --- /dev/null +++ b/internal/db/sqlc/postgres/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) + } + if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { + return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) + } + if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { + return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteBlockStmt != nil { + if cerr := q.deleteBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) + } + } + if q.getBlockByHeightStmt != nil { + if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) + } + } + if q.insertBlockStmt != nil { + if cerr := q.insertBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + insertBlockStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + insertBlockStmt: q.insertBlockStmt, + } +} diff --git a/internal/db/sqlc/postgres/models.go b/internal/db/sqlc/postgres/models.go new file mode 100644 index 0000000000..3570205637 --- /dev/null +++ b/internal/db/sqlc/postgres/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +type Block struct { + BlockHeight int32 + HeaderHash []byte + Timestamp int64 +} diff --git a/internal/db/sqlc/postgres/querier.go b/internal/db/sqlc/postgres/querier.go new file mode 100644 index 0000000000..77972c1619 --- /dev/null +++ b/internal/db/sqlc/postgres/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +import ( + "context" +) + +type Querier interface { + DeleteBlock(ctx context.Context, blockHeight int32) error + GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + InsertBlock(ctx context.Context, arg InsertBlockParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/sqlc/sqlite/blocks.sql.go b/internal/db/sqlc/sqlite/blocks.sql.go new file mode 100644 index 0000000000..62f6afa0f5 --- /dev/null +++ b/internal/db/sqlc/sqlite/blocks.sql.go @@ -0,0 +1,49 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: blocks.sql + +package sqlcsqlite + +import ( + "context" +) + +const DeleteBlock = `-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = ? +` + +func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int64) error { + _, err := q.exec(ctx, q.deleteBlockStmt, DeleteBlock, blockHeight) + return err +} + +const GetBlockByHeight = `-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = ? +` + +func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) { + row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + return i, err +} + +const InsertBlock = `-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES (?, ?, ?) +` + +type InsertBlockParams struct { + BlockHeight int64 + HeaderHash []byte + Timestamp int64 +} + +func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + return err +} diff --git a/internal/db/sqlc/sqlite/db.go b/internal/db/sqlc/sqlite/db.go new file mode 100644 index 0000000000..44f87a934f --- /dev/null +++ b/internal/db/sqlc/sqlite/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) + } + if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { + return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) + } + if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { + return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteBlockStmt != nil { + if cerr := q.deleteBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) + } + } + if q.getBlockByHeightStmt != nil { + if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) + } + } + if q.insertBlockStmt != nil { + if cerr := q.insertBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + insertBlockStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + insertBlockStmt: q.insertBlockStmt, + } +} diff --git a/internal/db/sqlc/sqlite/models.go b/internal/db/sqlc/sqlite/models.go new file mode 100644 index 0000000000..99e67f9744 --- /dev/null +++ b/internal/db/sqlc/sqlite/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +type Block struct { + BlockHeight int64 + HeaderHash []byte + Timestamp int64 +} diff --git a/internal/db/sqlc/sqlite/querier.go b/internal/db/sqlc/sqlite/querier.go new file mode 100644 index 0000000000..8f91c3cb17 --- /dev/null +++ b/internal/db/sqlc/sqlite/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +import ( + "context" +) + +type Querier interface { + DeleteBlock(ctx context.Context, blockHeight int64) error + GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + InsertBlock(ctx context.Context, arg InsertBlockParams) error +} + +var _ Querier = (*Queries)(nil) From 3d67ecd5aebc1cbe825bce72ae869530b6692e71 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 12 Nov 2025 10:00:54 -0300 Subject: [PATCH 010/695] wallet+Makefile: move sql db to wallet/internal --- Makefile | 2 +- .../internal}/db/migrations/postgres/000001_blocks.down.sql | 0 .../internal}/db/migrations/postgres/000001_blocks.up.sql | 0 .../internal}/db/migrations/sqlite/000001_blocks.down.sql | 0 .../internal}/db/migrations/sqlite/000001_blocks.up.sql | 0 {internal => wallet/internal}/db/queries/postgres/blocks.sql | 0 {internal => wallet/internal}/db/queries/sqlite/blocks.sql | 0 {internal => wallet/internal}/db/sqlc/postgres/blocks.sql.go | 0 {internal => wallet/internal}/db/sqlc/postgres/db.go | 0 {internal => wallet/internal}/db/sqlc/postgres/models.go | 0 {internal => wallet/internal}/db/sqlc/postgres/querier.go | 0 {internal => wallet/internal}/db/sqlc/sqlc.yaml | 0 {internal => wallet/internal}/db/sqlc/sqlite/blocks.sql.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/db.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/models.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/querier.go | 0 16 files changed, 1 insertion(+), 1 deletion(-) rename {internal => wallet/internal}/db/migrations/postgres/000001_blocks.down.sql (100%) rename {internal => wallet/internal}/db/migrations/postgres/000001_blocks.up.sql (100%) rename {internal => wallet/internal}/db/migrations/sqlite/000001_blocks.down.sql (100%) rename {internal => wallet/internal}/db/migrations/sqlite/000001_blocks.up.sql (100%) rename {internal => wallet/internal}/db/queries/postgres/blocks.sql (100%) rename {internal => wallet/internal}/db/queries/sqlite/blocks.sql (100%) rename {internal => wallet/internal}/db/sqlc/postgres/blocks.sql.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/db.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/models.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/querier.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlc.yaml (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/blocks.sql.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/db.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/models.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/querier.go (100%) diff --git a/Makefile b/Makefile index aaf745e416..d4829dd895 100644 --- a/Makefile +++ b/Makefile @@ -162,7 +162,7 @@ tidy-module-check: tidy-module #? sqlc: Generate sql models and queries in Go sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f internal/db/sqlc/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f wallet/internal/db/sqlc/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/internal/db/migrations/postgres/000001_blocks.down.sql b/wallet/internal/db/migrations/postgres/000001_blocks.down.sql similarity index 100% rename from internal/db/migrations/postgres/000001_blocks.down.sql rename to wallet/internal/db/migrations/postgres/000001_blocks.down.sql diff --git a/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql similarity index 100% rename from internal/db/migrations/postgres/000001_blocks.up.sql rename to wallet/internal/db/migrations/postgres/000001_blocks.up.sql diff --git a/internal/db/migrations/sqlite/000001_blocks.down.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.down.sql similarity index 100% rename from internal/db/migrations/sqlite/000001_blocks.down.sql rename to wallet/internal/db/migrations/sqlite/000001_blocks.down.sql diff --git a/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql similarity index 100% rename from internal/db/migrations/sqlite/000001_blocks.up.sql rename to wallet/internal/db/migrations/sqlite/000001_blocks.up.sql diff --git a/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql similarity index 100% rename from internal/db/queries/postgres/blocks.sql rename to wallet/internal/db/queries/postgres/blocks.sql diff --git a/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql similarity index 100% rename from internal/db/queries/sqlite/blocks.sql rename to wallet/internal/db/queries/sqlite/blocks.sql diff --git a/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go similarity index 100% rename from internal/db/sqlc/postgres/blocks.sql.go rename to wallet/internal/db/sqlc/postgres/blocks.sql.go diff --git a/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go similarity index 100% rename from internal/db/sqlc/postgres/db.go rename to wallet/internal/db/sqlc/postgres/db.go diff --git a/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go similarity index 100% rename from internal/db/sqlc/postgres/models.go rename to wallet/internal/db/sqlc/postgres/models.go diff --git a/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go similarity index 100% rename from internal/db/sqlc/postgres/querier.go rename to wallet/internal/db/sqlc/postgres/querier.go diff --git a/internal/db/sqlc/sqlc.yaml b/wallet/internal/db/sqlc/sqlc.yaml similarity index 100% rename from internal/db/sqlc/sqlc.yaml rename to wallet/internal/db/sqlc/sqlc.yaml diff --git a/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go similarity index 100% rename from internal/db/sqlc/sqlite/blocks.sql.go rename to wallet/internal/db/sqlc/sqlite/blocks.sql.go diff --git a/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go similarity index 100% rename from internal/db/sqlc/sqlite/db.go rename to wallet/internal/db/sqlc/sqlite/db.go diff --git a/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go similarity index 100% rename from internal/db/sqlc/sqlite/models.go rename to wallet/internal/db/sqlc/sqlite/models.go diff --git a/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go similarity index 100% rename from internal/db/sqlc/sqlite/querier.go rename to wallet/internal/db/sqlc/sqlite/querier.go From a0e844c3aa3b03df85e1259dc82dbec97fcd5104 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 4 Nov 2025 14:07:50 +0800 Subject: [PATCH 011/695] wallet: introduce db layer interfaces --- wallet/internal/db/data_types.go | 887 +++++++++++++++++++++++++++++++ wallet/internal/db/interface.go | 211 ++++++++ 2 files changed, 1098 insertions(+) create mode 100644 wallet/internal/db/data_types.go create mode 100644 wallet/internal/db/interface.go diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go new file mode 100644 index 0000000000..63f3098ef2 --- /dev/null +++ b/wallet/internal/db/data_types.go @@ -0,0 +1,887 @@ +// Package db provides a database-agnostic interface for wallet data storage, +// defining the core data types and store interfaces for wallets, accounts, +// addresses, transactions, and UTXOs. +package db + +import ( + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +// ============================================================================ +// Data Types & Method Parameters +// ============================================================================ + +// KeyScope represents the BIP-44 key scope as defined in BIP-43. It is used +// to organize keys based on their purpose and coin type, providing a +// hierarchical structure for key derivation. +type KeyScope struct { + // Purpose is the purpose number for the scope, as defined in BIP-43. + Purpose uint32 + + // Coin is the coin type number for the scope, as defined in BIP-44. + Coin uint32 +} + +// AddressType specifies the type of a managed address. This is used to +// identify the script type of an address, such as P2PKH, P2SH, P2WKH, etc. +type AddressType uint8 + +const ( + // PubKeyHash represents a pay-to-pubkey-hash (P2PKH) address. + PubKeyHash AddressType = iota + + // ScriptHash represents a pay-to-script-hash (P2SH) address. + ScriptHash + + // WitnessPubKey represents a pay-to-witness-pubkey-hash (P2WKH) + // address. + WitnessPubKey + + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH + // address. + NestedWitnessPubKey + + // TaprootPubKey represents a pay-to-taproot (P2TR) address. + TaprootPubKey +) + +const ( + // BIP0044Purpose is the purpose field for BIP0044 derivation. + BIP0044Purpose = 44 + + // BIP0049Purpose is the purpose field for BIP0049 derivation. + BIP0049Purpose = 49 + + // BIP0084Purpose is the purpose field for BIP0084 derivation. + BIP0084Purpose = 84 + + // BIP0086Purpose is the purpose field for BIP0086 derivation. + BIP0086Purpose = 86 +) + +var ( + // KeyScopeBIP0049Plus is the key scope of our modified BIP0049 + // derivation. We say this is BIP0049 "plus", as it acts as an + // optimization for fee savings. Standard BIP0049 uses P2SH-wrapped + // SegWit for both external (receive) and internal (change) addresses. + // This scheme uses P2SH-wrapped SegWit for external addresses (to + // ensure backward compatibility with senders using legacy wallets) but + // uses Native SegWit (P2WKH) for internal change addresses. Since the + // wallet controls its own change, it can use the more efficient Native + // SegWit format to reduce transaction weight and save on fees when + // spending that change later. + KeyScopeBIP0049Plus = KeyScope{ + Purpose: BIP0049Purpose, + Coin: 0, + } + + // KeyScopeBIP0084 is the key scope for BIP0084 derivation. BIP0084 + // will be used to derive all p2wkh addresses. + KeyScopeBIP0084 = KeyScope{ + Purpose: BIP0084Purpose, + Coin: 0, + } + + // KeyScopeBIP0086 is the key scope for BIP0086 derivation. BIP0086 + // will be used to derive all p2tr addresses. + KeyScopeBIP0086 = KeyScope{ + Purpose: BIP0086Purpose, + Coin: 0, + } + + // KeyScopeBIP0044 is the key scope for BIP0044 derivation. Legacy + // wallets will only be able to use this key scope, and no keys beyond + // it. + KeyScopeBIP0044 = KeyScope{ + Purpose: BIP0044Purpose, + Coin: 0, + } + + // ScopeAddrMap is a map from the default key scopes to the scope + // address schema for each scope type. + ScopeAddrMap = map[KeyScope]ScopeAddrSchema{ + KeyScopeBIP0049Plus: { + ExternalAddrType: NestedWitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + KeyScopeBIP0084: { + ExternalAddrType: WitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + KeyScopeBIP0086: { + ExternalAddrType: TaprootPubKey, + InternalAddrType: TaprootPubKey, + }, + KeyScopeBIP0044: { + InternalAddrType: PubKeyHash, + ExternalAddrType: PubKeyHash, + }, + } +) + +// Tapscript represents a Taproot script leaf, which includes the script itself +// and its corresponding control block. This is used for spending Taproot +// outputs. +type Tapscript struct { + // ControlBlock is the control block for the Taproot script, which is + // required to reveal the script path during spending. + ControlBlock []byte + + // Script is the actual script code of the Taproot leaf. + Script []byte +} + +// -------------------- +// WalletStore Types +// -------------------- + +// WalletInfo contains the static properties of a wallet. This struct provides a +// summary of the wallet's configuration and state. +type WalletInfo struct { + // ID is the unique identifier for the wallet. + // + // NOTE: This is a uint32 rather than a uint64 to ensure compatibility + // with standard SQL databases (PostgreSQL, SQLite) which typically use + // signed 64-bit integers for their BIGINT/INTEGER types. A uint64 can + // overflow a signed 64-bit integer, whereas a uint32 fits comfortably. + ID uint32 + + // Name is the human-readable name of the wallet. + Name string + + // IsImported indicates whether the wallet was created from an existing + // seed or was created as a new wallet. + IsImported bool + + // IsWatchOnly indicates whether the wallet is in watch-only mode, + // meaning it does not have private keys and cannot sign transactions. + IsWatchOnly bool + + // Birthday is the timestamp of the wallet's creation, used as a + // starting point for rescans. + Birthday time.Time + + // BirthdayBlock is the block hash and height from which to start a + // rescan. + BirthdayBlock Block + + // SyncedTo represents the wallet's current synchronization state with + // the blockchain. + SyncedTo *Block +} + +// Block defines a block's hash, height, and timestamp. This is used to +// represent a block's identity and position in the blockchain. +type Block struct { + // Hash is the 32-byte hash of the block. + Hash chainhash.Hash + + // Height is the height of the block in the blockchain. + Height uint32 + + // Timestamp is the timestamp of the block, which is used for wallet + // synchronization and rescan operations. + Timestamp time.Time +} + +// CreateWalletParams contains the parameters required to create a new wallet. +type CreateWalletParams struct { + // Name is the name of the new wallet. + Name string + + // IsImported should be set to true if the wallet is being created from + // an existing seed. + IsImported bool + + // IsWatchOnly indicates whether the wallet is being created in + // watch-only mode. + IsWatchOnly bool + + // EncryptedMasterPrivKey is the encrypted master HD private key. + EncryptedMasterPrivKey []byte + + // EncryptedMasterPubKey is the encrypted master HD public key. + EncryptedMasterPubKey []byte + + // MasterKeyPubParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master public key. + MasterKeyPubParams []byte + + // MasterKeyPrivParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master private key. + MasterKeyPrivParams []byte + + // EncryptedCryptoPrivKey is the encrypted private crypto key, used to + // protect private keys in the database. + EncryptedCryptoPrivKey []byte + + // EncryptedCryptoPubKey is the encrypted public crypto key, used to + // protect public keys in the database. + EncryptedCryptoPubKey []byte + + // EncryptedCryptoScriptKey is the encrypted script crypto key, used to + // protect scripts in the database. + EncryptedCryptoScriptKey []byte +} + +// UpdateWalletParams contains the parameters for updating a wallet's +// properties. Fields are pointers to allow for partial updates. +type UpdateWalletParams struct { + // WalletID is the ID of the wallet to update. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Birthday is the new birthday for the wallet. + Birthday *time.Time + + // BirthdayBlock is the new birthday block for the wallet. + BirthdayBlock *Block + + // SyncedTo is the new synchronization state for the wallet. + SyncedTo *Block +} + +// ChangePassphraseParams contains the parameters for changing a wallet's +// passphrase. +type ChangePassphraseParams struct { + // WalletID is the ID of the wallet to update. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // OldPassphrase is the current passphrase. + OldPassphrase []byte + + // NewPassphrase is the new passphrase to set. + NewPassphrase []byte + + // IsPrivate specifies whether to change the private (true) or public + // (false) passphrase. + IsPrivate bool +} + +// -------------------- +// AccountStore Types +// -------------------- + +// AccountInfo contains all information about a single account, including its +// properties and balances. +type AccountInfo struct { + // AccountNumber is the unique identifier for the account. + AccountNumber uint32 + + // AccountName is the human-readable name of the account. + AccountName string + + // ExternalKeyCount is the number of external keys that have been + // derived. + ExternalKeyCount uint32 + + // InternalKeyCount is the number of internal (change) keys that have + // been derived. + InternalKeyCount uint32 + + // ImportedKeyCount is the number of imported keys in the account. + ImportedKeyCount uint32 + + // ConfirmedBalance is the total balance of the account from confirmed + // transactions. + ConfirmedBalance btcutil.Amount + + // UnconfirmedBalance is the total balance of the account from + // unconfirmed transactions. + UnconfirmedBalance btcutil.Amount + + // IsWatchOnly indicates whether the account is in watch-only mode. + IsWatchOnly bool + + // KeyScope is the key scope the account belongs to. This determines the + // derivation path and the default address schema. + KeyScope KeyScope +} + +// ScopeAddrSchema is the address schema of a particular KeyScope. This will be +// persisted within the database, and will be consulted when deriving any keys +// for a particular scope to know how to encode the public keys as addresses. +type ScopeAddrSchema struct { + // ExternalAddrType is the address type for all keys within branch 0. + ExternalAddrType AddressType + + // InternalAddrType is the address type for all keys within branch 1 + // (change addresses). + InternalAddrType AddressType +} + +// CreateAccountParams contains the parameters for creating a new account. +type CreateAccountParams struct { + // WalletID is the ID of the wallet to create the account in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope for the new account. + Scope KeyScope + + // Name is the name of the new account. + Name string +} + +// ImportAccountParams contains the data required to import an account from an +// extended key. +type ImportAccountParams struct { + // WalletID is the ID of the wallet to import the account into. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Name is the name of the account to import. + Name string + + // AccountKey is the extended key for the account. + AccountKey *hdkeychain.ExtendedKey + + // MasterKeyFingerprint is the fingerprint of the master key. + MasterKeyFingerprint uint32 + + // Scope is the key scope for the account. The address schema for the + // account will be determined by the default mapping for this scope. + Scope KeyScope +} + +// ImportAccountResult holds the results of an account import operation. +type ImportAccountResult struct { + // AccountProperties contains the properties of the imported account. + AccountProperties *AccountProperties + + // ExternalAddrs contains the derived external addresses if the import + // was a dry run. + ExternalAddrs []AddressInfo + + // InternalAddrs contains the derived internal addresses if the import + // was a dry run. + InternalAddrs []AddressInfo +} + +// AccountProperties contains properties associated with each account, such as +// the account name, number, and the nubmer of derived and imported keys. +type AccountProperties struct { + // AccountNumber is the internal number used to reference the account. + AccountNumber uint32 + + // AccountName is the user-identifying name of the account. + AccountName string + + // ExternalKeyCount is the number of internal keys that have been + // derived for the account. + ExternalKeyCount uint32 + + // InternalKeyCount is the number of internal keys that have been + // derived for the account. + InternalKeyCount uint32 + + // ImportedKeyCount is the number of imported keys found within the + // account. + ImportedKeyCount uint32 + + // AccountPubKey is the account's public key that can be used to + // derive any address relevant to said account. + // + // NOTE: This may be nil for imported accounts. + AccountPubKey *hdkeychain.ExtendedKey + + // MasterKeyFingerprint represents the fingerprint of the root key + // corresponding to the master public key (also known as the key with + // derivation path m/). This may be required by some hardware wallets + // for proper identification and signing. + MasterKeyFingerprint uint32 + + // KeyScope is the key scope the account belongs to. + KeyScope KeyScope + + // IsWatchOnly indicates whether the is set up as watch-only, i.e., it + // doesn't contain any private key information. + IsWatchOnly bool + + // AddrSchema, if non-nil, specifies an address schema override for + // address generation only applicable to the account. + AddrSchema *ScopeAddrSchema +} + +// GetAccountQuery contains the parameters for querying a single account. The +// query must specify either the account name or the account number. Using +// pointers for these fields allows the query to be unambiguous, as a nil value +// indicates that the field should not be used for filtering. This avoids the +// "zero value" problem, where 0 or an empty string could be valid query +// targets. +type GetAccountQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope of the account. + Scope KeyScope + + // Name is the name of the account to query. If nil, the query will be + // performed using the AccountNumber. + Name *string + + // AccountNumber is the number of the account to query. If nil, the + // query will be performed using the Name. + AccountNumber *uint32 +} + +// ListAccountsQuery holds the set of options for a ListAccounts query. +type ListAccountsQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is an optional filter to list accounts only for a specific key + // scope. + Scope *KeyScope + + // Name is an optional filter to list accounts only with a specific + // name. + Name *string +} + +// RenameAccountParams contains the parameters for renaming an account. The +// account can be identified by either its old name or its account number. +type RenameAccountParams struct { + // WalletID is the ID of the wallet containing the account. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope of the account. + Scope KeyScope + + // OldName is the current name of the account. This is used to identify + // the account if AccountNumber is not provided. + OldName string + + // AccountNumber is the number of the account to rename. This is used + // to identify the account if OldName is not provided. + AccountNumber *uint32 + + // NewName is the new name for the account. + NewName string +} + +// -------------------- +// AddressStore Types +// -------------------- + +// AddressInfo represents a wallet-managed address, including its properties and +// derivation information. +type AddressInfo struct { + // Address is the human-readable address string. + Address address.Address + + // Internal indicates whether the address is for internal (change) use. + Internal bool + + // Compressed indicates whether the address is compressed. + Compressed bool + + // Used indicates whether the address has been used in a transaction. + Used bool + + // IsWatchOnly indicates whether the wallet has the private key for + // this address. + IsWatchOnly bool + + // AddrType is the type of the address (P2PKH, P2SH, etc.). + AddrType AddressType + + // DerivationInfo contains the BIP-32 derivation path information for + // the address. This will be nil for imported addresses that are not + // part of an HD account. + DerivationInfo *DerivationInfo + + // Script is the script associated with the address, if any. + Script []byte +} + +// NewAddressParams contains the parameters for creating a new address. +type NewAddressParams struct { + // WalletID is the ID of the wallet to create the address in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AccountName is the name of the account to create the address for. + AccountName string + + // Scope is the key scope for the new address. + Scope KeyScope + + // Change indicates whether to create a change address (true) or an + // external address (false). + Change bool +} + +// ImportAddressParams encapsulates all the data needed to store a new, imported +// address, script, or private key. All imported addresses are automatically +// assigned to the wallet's logical "imported" account. The presence of a +// private key determines whether the address will be spendable or watch-only. +type ImportAddressParams struct { + // WalletID is the ID of the wallet to import the address into. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // PrivateKey is the private key to import, in WIF format. If this is + // provided, the address will be spendable. If nil, the import will be + // watch-only. + PrivateKey *btcutil.WIF + + // PubKey is the public key to import for a watch-only address. This + // field is only used if PrivateKey is nil. + PubKey *btcec.PublicKey + + // Tapscript is the Taproot script to import for a watch-only address. + // This field is only used if PrivateKey is nil. + Tapscript *Tapscript + + // Script is the generic script to import for a watch-only address. + // This field is only used if PrivateKey is nil. + Script []byte +} + +// GetPrivateKeyParams contains the parameters for retrieving a private key. +type GetPrivateKeyParams struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address for which to retrieve the private key. + Address address.Address +} + +// GetAddressQuery contains the parameters for querying an address. +type GetAddressQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address to query. + Address address.Address +} + +// ListAddressesQuery contains the parameters for listing addresses. +type ListAddressesQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AccountName is the name of the account to list addresses for. + AccountName string + + // Scope is the key scope of the account. + Scope KeyScope +} + +// MarkAddressAsUsedParams contains the parameters for marking an address as +// used. +type MarkAddressAsUsedParams struct { + // WalletID is the ID of the wallet containing the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address to mark as used. + Address address.Address +} + +// DerivationInfo contains the BIP-32 derivation path information for a key. +type DerivationInfo struct { + // KeyScope is the key scope of the derivation path. + KeyScope KeyScope + + // MasterKeyFingerprint is the fingerprint of the master key. + MasterKeyFingerprint uint32 + + // Account is the account number of the derivation path. + Account uint32 + + // Branch is the branch number of the derivation path (0 for external, + // 1 for internal). + Branch uint32 + + // Index is the index of the key in the branch. + Index uint32 +} + +// -------------------- +// TxStore Types +// -------------------- + +// TxInfo represents the details of a transaction relevant to the wallet. +type TxInfo struct { + // Hash is the transaction hash. + Hash chainhash.Hash + + // SerializedTx is the serialized transaction. + SerializedTx []byte + + // Received is the timestamp when the transaction was received. + Received time.Time + + // Block contains metadata about the block that includes the + // transaction. This will be nil for unmined (unconfirmed) transactions + // and non-nil for mined (confirmed) transactions. + Block *Block + + // Label is a user-defined label for the transaction. + Label string +} + +// CreateTxParams contains the parameters for creating a new transaction record. +type CreateTxParams struct { + // WalletID is the ID of the wallet to create the transaction in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Tx is the transaction to record. + Tx *wire.MsgTx + + // Label is an optional label for the transaction. + Label string + + // Credits lists the outputs of the transaction that are controlled by + // the wallet. + Credits []CreditData +} + +// CreditData contains the information needed to record a transaction credit. +// It acts as an explicit instruction to the CreateTx method, identifying which +// of the transaction's outputs belongs to the wallet and should be recorded as +// a new UTXO. This serves as a performance optimization, preventing the +// database layer from needing to parse every transaction output and query the +// address manager to determine ownership. +type CreditData struct { + // Index is the output index of the credit. + Index uint32 + + // Address is the address that received the credit. + Address address.Address +} + +// UpdateTxParams contains the parameters for updating a transaction record. +// Fields are pointers to allow for partial updates. +type UpdateTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to update. + Txid chainhash.Hash + + // Block is the new block metadata for the transaction. + Block *Block + + // Label is the new label for the transaction. + Label *string +} + +// GetTxQuery contains the parameters for querying a transaction. While a +// transaction hash (TxHash) is globally unique on the blockchain, the WalletID +// is necessary to retrieve wallet-specific metadata (e.g., labels, credits, +// debits) associated with that transaction. In a multi-wallet database, the +// same transaction might be relevant to multiple wallets, but its context +// (e.g., whether it's a credit or debit, and any custom labels) will differ +// for each wallet. The WalletID ensures the query returns the transaction's +// details from the correct wallet's perspective. +type GetTxQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to query. + Txid chainhash.Hash +} + +// ListTxnsQuery contains the parameters for listing transactions. +type ListTxnsQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // StartHeight is the starting block height for the query. + StartHeight uint32 + + // EndHeight is the ending block height for the query. + EndHeight uint32 + + // UnminedOnly, if true, will return only unmined (unconfirmed) + // transactions. If this is set, StartHeight and EndHeight will be + // ignored. + UnminedOnly bool +} + +// DeleteTxParams contains the parameters for the DeleteTx method. +type DeleteTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to delete. + Txid chainhash.Hash +} + +// -------------------- +// UTXOStore Types +// -------------------- + +// UtxoInfo represents an unspent transaction output (UTXO). +type UtxoInfo struct { + // OutPoint is the outpoint of the UTXO. + OutPoint wire.OutPoint + + // Amount is the value of the UTXO. + Amount btcutil.Amount + + // PkScript is the public key script of the UTXO. + PkScript []byte + + // Received is the timestamp when the UTXO was received. + Received time.Time + + // FromCoinBase indicates whether the UTXO is from a coinbase + // transaction. + FromCoinBase bool + + // Height is the block height of the UTXO. + Height uint32 +} + +// GetUtxoQuery contains the parameters for querying a UTXO. +type GetUtxoQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // OutPoint is the outpoint of the UTXO to query. + OutPoint wire.OutPoint +} + +// ListUtxosQuery holds the set of options for a ListUTXOs query. +type ListUtxosQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Account is an optional filter to list UTXOs only for a specific + // account. + Account *uint32 + + // MinConfs is the minimum number of confirmations for a UTXO to be + // included. + MinConfs int32 + + // MaxConfs is the maximum number of confirmations for a UTXO to be + // included. + MaxConfs int32 +} + +// LeaseOutputParams contains the parameters for leasing a UTXO. +type LeaseOutputParams struct { + // WalletID is the ID of the wallet containing the UTXO. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // ID is the lock ID for the UTXO. + ID [32]byte + + // OutPoint is the outpoint of the UTXO to lock. + OutPoint wire.OutPoint + + // Duration is the duration to lock the UTXO for. + Duration time.Duration +} + +// ReleaseOutputParams contains the parameters for releasing a UTXO lease. +type ReleaseOutputParams struct { + // WalletID is the ID of the wallet containing the UTXO. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // ID is the lock ID of the UTXO to unlock. + ID [32]byte + + // OutPoint is the outpoint of the UTXO to unlock. + OutPoint wire.OutPoint +} + +// LeasedOutput represents a UTXO that is currently locked. +type LeasedOutput struct { + // OutPoint is the outpoint of the locked UTXO. + OutPoint wire.OutPoint + + // LockID is the ID of the lock. + LockID LockID + + // Expiration is the time when the lock expires. + Expiration time.Time +} + +// BalanceParams contains the parameters for the Balance method. +type BalanceParams struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // MinConfirms is the minimum number of confirmations a UTXO must have + // to be included in the balance calculation. + MinConfirms int32 +} + +// LockID represents a unique context-specific ID assigned to an output lock. +type LockID [32]byte diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go new file mode 100644 index 0000000000..ab49668f87 --- /dev/null +++ b/wallet/internal/db/interface.go @@ -0,0 +1,211 @@ +package db + +import ( + "context" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" +) + +// WalletStore defines the methods for wallet-level operations. +type WalletStore interface { + // CreateWallet creates a new wallet in the database with the provided + // parameters. It returns the ID of the newly created wallet or an error + // if the creation fails. + CreateWallet(ctx context.Context, params CreateWalletParams) ( + *WalletInfo, error) + + // GetWallet retrieves information about a wallet given its name. It + // returns a WalletInfo struct containing the wallet's properties or an + // error if the wallet is not found. + GetWallet(ctx context.Context, name string) (*WalletInfo, error) + + // ListWallets returns a slice of WalletInfo for all wallets stored in + // the database. It returns an empty slice if no wallets are found, or + // an error if the retrieval fails. + ListWallets(ctx context.Context) ([]WalletInfo, error) + + // UpdateWallet updates various properties of a wallet, such as its + // birthday, birthday block, or sync state. The specific fields to + // update are provided in the UpdateWalletParams struct. It returns an + // error if the update fails. + UpdateWallet(ctx context.Context, params UpdateWalletParams) error + + // GetEncryptedHDSeed retrieves the encrypted Hierarchical + // Deterministic (HD) seed of the wallet. This seed is sensitive + // information and is returned in its encrypted form. It returns the + // encrypted seed as a byte slice or an error if the retrieval fails. + GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) + + // ChangePassphrase changes the passphrase for the wallet. It takes the + // old and new passphrases as byte slices, and a boolean indicating + // whether to change the private passphrase (true) or the public + // passphrase (false). It returns an error if the passphrase change + // fails (e.g., incorrect old passphrase). + ChangePassphrase(ctx context.Context, + params ChangePassphraseParams) error +} + +// AccountStore defines the database actions for managing accounts. +type AccountStore interface { + // CreateAccount creates a new account with the given name and scope. It + // returns the properties of the newly created account or an error if + // the + // creation fails. + CreateAccount(ctx context.Context, params CreateAccountParams) ( + *AccountInfo, error) + + // ImportAccount imports an account from an extended key. This method + // supports normal imports, imports with a specific scope, and dry-run + // imports. The behavior is controlled by the fields in the + // ImportAccountParams struct. It returns the properties of the imported + // account and derived addresses (for dry runs) or an error if the + // import fails. The returned addresses are of type AddressInfo. + ImportAccount(ctx context.Context, params ImportAccountParams) ( + *ImportAccountResult, error) + + // GetAccount retrieves information about a specific account, + // identified by its name or account number within a given key scope. + // It returns an AccountInfo struct containing the account's properties + // or an error if the account is not found. + GetAccount(ctx context.Context, query GetAccountQuery) ( + *AccountInfo, error) + + // ListAccounts returns a slice of AccountInfo for all accounts, + // optionally filtered by name or key scope. It returns an empty slice + // if no accounts are found. + ListAccounts(ctx context.Context, query ListAccountsQuery) ( + []AccountInfo, error) + + // RenameAccount changes the name of an account. The account can be + // identified by its old name or its account number. It returns an + // error if the renaming fails. + RenameAccount(ctx context.Context, params RenameAccountParams) error +} + +// AddressStore defines the database actions for managing addresses. +type AddressStore interface { + // NewAddress creates a new address for a given account and key scope. + // It returns the newly created address or an error if the creation + // fails. + NewAddress(ctx context.Context, params NewAddressParams) ( + address.Address, error) + + // ImportAddress imports a new address, script, or private key. If a + // private key is provided in the parameters, the address will be + // spendable. Otherwise, it will be imported as watch-only. It returns + // information about the imported address or an error if the import + // fails. + ImportAddress(ctx context.Context, params ImportAddressParams) ( + *AddressInfo, error) + + // GetAddress retrieves information about a specific address. It + // returns an AddressInfo struct containing the address's properties or + // an error if the address is not found. + GetAddress(ctx context.Context, query GetAddressQuery) ( + *AddressInfo, error) + + // ListAddresses returns a slice of AddressInfo for all addresses in a + // given account. It returns an empty slice if no addresses are found. + ListAddresses(ctx context.Context, query ListAddressesQuery) ( + []AddressInfo, error) + + // MarkAddressAsUsed marks a given address as used. This is used to + // ensure that the address is not reused. + MarkAddressAsUsed(ctx context.Context, + params MarkAddressAsUsedParams) error + + // GetPrivateKey retrieves the private key for a given address. This + // method is ONLY valid for addresses that were imported with a private + // key. It will return an error for derived HD addresses and watch-only + // imports. + GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( + *btcec.PrivateKey, error) +} + +// TxStore defines the database actions for managing transaction records. +type TxStore interface { + // CreateTx atomically records a transaction and its associated credits + // in the database. This is a single atomic operation that also handles + // the corresponding UTXO state changes: it will delete any UTXOs spent + // by the new transaction's inputs and create new UTXOs for any of its + // outputs that are spendable by the wallet. This ensures that the + // transaction record and the UTXO set are always consistent. + CreateTx(ctx context.Context, params CreateTxParams) error + + // UpdateTx updates an existing transaction record in the database. It + // takes a context and UpdateTxParams, returning an error if the + // transaction cannot be found or updated. + UpdateTx(ctx context.Context, params UpdateTxParams) error + + // GetTx retrieves a transaction record by its hash. It takes a context + // and GetTxQuery, returning a TxInfo struct or an error if the + // transaction is not found. Note that the `Credits` and `Debits` fields + // of the returned `TxInfo` are not stored directly in the transaction + // record; they are derived by querying the UTXO store and represent + // wallet-specific information about the transaction's impact on the + // UTXO set. + GetTx(ctx context.Context, query GetTxQuery) (*TxInfo, error) + + // ListTxns returns a slice of transaction information based on the + // provided query parameters. It takes a context and ListTxnsQuery, + // returning a slice of TxInfo or an error if the retrieval fails. + ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) + + // DeleteTx removes an unmined transaction from the store. It takes a + // context and DeleteTxParams, returning an error if the transaction is + // not found or the deletion fails. + DeleteTx(ctx context.Context, params DeleteTxParams) error + + // RollbackToBlock removes all blocks at and after a given height, + // moving any transactions within those blocks back to the unconfirmed + // pool. This operation is performed as a single, atomic database + // transaction to ensure data integrity. Breaking it into smaller, + // separate interface methods would risk leaving the database in an + // inconsistent state if an error occurred mid-process. The current + // approach guarantees that the rollback is either fully completed or + // not at all. + // + // TODO(yy): explore performance improvement for this method. + RollbackToBlock(ctx context.Context, height uint32) error +} + +// UTXOStore defines the database actions for managing the UTXO set. +type UTXOStore interface { + // GetUtxo retrieves a single unspent transaction output (UTXO) by its + // outpoint. It returns a UtxoInfo struct containing the UTXO's details + // or an error if the UTXO is not found or has been spent. + GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) + + // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) + // that match the provided query parameters. This can be used to list + // all UTXOs or filter them by account or confirmation status. + ListUTXOs(ctx context.Context, query ListUtxosQuery) ([]UtxoInfo, error) + + // LeaseOutput locks a specific UTXO for a given duration, preventing + // it from being used in coin selection. This is useful for reserving + // UTXOs for a specific purpose, such as a pending transaction. The + // method returns the full lease information, including its expiration + // time. + LeaseOutput(ctx context.Context, params LeaseOutputParams) ( + *LeasedOutput, error) + + // ReleaseOutput unlocks a previously leased UTXO, making it available + // for coin selection again. The lock ID must match the one used to + // lease the output. + ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error + + // ListLeasedOutputs returns a slice of all currently leased UTXOs. + // This can be used to inspect which outputs are currently locked and + // when their leases expire. + ListLeasedOutputs(ctx context.Context, walletID uint32) ( + []LeasedOutput, error) + + // Balance returns the total spendable balance of the wallet, + // calculated from the UTXO set. The minConfirms parameter specifies + // the minimum number of confirmations a UTXO must have to be included + // in the balance calculation. + Balance(ctx context.Context, params BalanceParams) ( + btcutil.Amount, error) +} From d324673357259cf56907996011a00599a13fd620 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 22 Nov 2025 13:04:21 -0300 Subject: [PATCH 012/695] wallet: add ManagerVersion to db WalletInfo --- wallet/internal/db/data_types.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 63f3098ef2..65b4e7a22e 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -160,6 +160,10 @@ type WalletInfo struct { // seed or was created as a new wallet. IsImported bool + // ManagerVersion is the version of the wallet manager that created this + // wallet. + ManagerVersion int32 + // IsWatchOnly indicates whether the wallet is in watch-only mode, // meaning it does not have private keys and cannot sign transactions. IsWatchOnly bool @@ -200,6 +204,10 @@ type CreateWalletParams struct { // an existing seed. IsImported bool + // ManagerVersion is the version of the wallet manager that created this + // wallet. + ManagerVersion int32 + // IsWatchOnly indicates whether the wallet is being created in // watch-only mode. IsWatchOnly bool From 7dbc5b3e3bf120eabbf0d5531623b7ba735523eb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 22 Nov 2025 13:06:40 -0300 Subject: [PATCH 013/695] wallet: refactor ChangePassphrase to UpdateWalletSecrets on db interface --- wallet/internal/db/data_types.go | 25 +++++++++++++++---------- wallet/internal/db/interface.go | 10 +++------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 65b4e7a22e..4b9ef87df7 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -258,24 +258,29 @@ type UpdateWalletParams struct { SyncedTo *Block } -// ChangePassphraseParams contains the parameters for changing a wallet's -// passphrase. -type ChangePassphraseParams struct { +// UpdateWalletSecretsParams contains the parameters for updating a wallet's +// secrets. +type UpdateWalletSecretsParams struct { // WalletID is the ID of the wallet to update. // // NOTE: uint32 is used to ensure compatibility with standard SQL // databases (signed 64-bit integers). WalletID uint32 - // OldPassphrase is the current passphrase. - OldPassphrase []byte + // MasterPrivParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master private key. + MasterPrivParams []byte + + // EncryptedCryptoPrivKey is the encrypted private crypto key, used to + // protect private keys in the database. + EncryptedCryptoPrivKey []byte - // NewPassphrase is the new passphrase to set. - NewPassphrase []byte + // EncryptedCryptoScriptKey is the encrypted script crypto key, used to + // protect scripts in the database. + EncryptedCryptoScriptKey []byte - // IsPrivate specifies whether to change the private (true) or public - // (false) passphrase. - IsPrivate bool + // EncryptedMasterHdPrivKey is the encrypted master HD private key. + EncryptedMasterHdPrivKey []byte } // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index ab49668f87..ee0b249ffb 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -38,13 +38,9 @@ type WalletStore interface { // encrypted seed as a byte slice or an error if the retrieval fails. GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) - // ChangePassphrase changes the passphrase for the wallet. It takes the - // old and new passphrases as byte slices, and a boolean indicating - // whether to change the private passphrase (true) or the public - // passphrase (false). It returns an error if the passphrase change - // fails (e.g., incorrect old passphrase). - ChangePassphrase(ctx context.Context, - params ChangePassphraseParams) error + // UpdateWalletSecrets updates the secrets for the wallet. + UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error } // AccountStore defines the database actions for managing accounts. From 5470fb3a6ed8dc0fd4ebde0c24e48b299690e75d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 01:35:09 -0300 Subject: [PATCH 014/695] wallet: add safecasting functions --- wallet/internal/db/safecasting.go | 88 +++++++++ wallet/internal/db/safecasting_test.go | 236 +++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 wallet/internal/db/safecasting.go create mode 100644 wallet/internal/db/safecasting_test.go diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go new file mode 100644 index 0000000000..1ddc2b0827 --- /dev/null +++ b/wallet/internal/db/safecasting.go @@ -0,0 +1,88 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "math" +) + +var ( + // ErrCastingOverflow is returned when a value cannot be safely + // cast to the desired type. + ErrCastingOverflow = errors.New("casting overflow") + + // ErrInvalidNullInt is returned when an invalid sql.NullInt is + // tried to be cast to an integer type. + ErrInvalidNullInt = errors.New("invalid NullInt") +) + +// int64ToUint32 safely casts an int64 to an uint32, returning an error +// if the value is out of range. +func int64ToUint32(v int64) (uint32, error) { + if v < 0 || v > math.MaxUint32 { + return 0, fmt.Errorf( + "could not cast %d to uint32: %w", + v, ErrCastingOverflow, + ) + } + + return uint32(v), nil +} + +// int64ToInt32 safely casts an int64 to an int32, returning an error +// if the value is out of range. +func int64ToInt32(v int64) (int32, error) { + if v < math.MinInt32 || v > math.MaxInt32 { + return 0, fmt.Errorf( + "could not cast %d to int32: %w", + v, ErrCastingOverflow, + ) + } + + return int32(v), nil +} + +// uint32ToInt32 safely casts an uint32 to an int32, returning an error +// if the value is out of range. +func uint32ToInt32(v uint32) (int32, error) { + if v > math.MaxInt32 { + return 0, fmt.Errorf( + "could not cast %d to int32: %w", + v, ErrCastingOverflow, + ) + } + + return int32(v), nil +} + +// uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning +// an error if the value is out of range. +func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { + toInt32, err := uint32ToInt32(v) + if err != nil { + return sql.NullInt32{}, err + } + + return sql.NullInt32{Int32: toInt32, Valid: true}, nil +} + +// nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning +// an error if the value is out of range or invalid. +func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { + if !n.Valid { + return 0, fmt.Errorf( + "could not cast invalid NullInt32 to uint32: %w", + ErrInvalidNullInt, + ) + } + + if n.Int32 < 0 { + return 0, fmt.Errorf( + "could not cast %d to uint32: %w", + n.Int32, ErrCastingOverflow, + ) + } + + return uint32(n.Int32), nil +} diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go new file mode 100644 index 0000000000..d87fb89466 --- /dev/null +++ b/wallet/internal/db/safecasting_test.go @@ -0,0 +1,236 @@ +package db + +import ( + "database/sql" + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestInt64ToUint32 checks that an int64 value is converted to uint32 only +// when it is non-negative and fits within the uint32 range. It should fail +// loudly for any value outside those bounds. +func TestInt64ToUint32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want uint32 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + { + name: "max uint32", + val: int64(math.MaxUint32), + want: math.MaxUint32, + }, + {name: "negative", val: -1, wantErr: true}, + { + name: "too large", + val: int64(math.MaxUint32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToUint32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestInt64ToInt32 checks that an int64 value is converted to int32 only +// when it fits within the signed 32 bit range. It should fail loudly for +// any value outside those limits. +func TestInt64ToInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want int32 + wantErr bool + }{ + { + name: "min int32", + val: int64(math.MinInt32), + want: math.MinInt32, + }, + { + name: "max int32", + val: int64(math.MaxInt32), + want: math.MaxInt32, + }, + { + name: "below min", + val: int64(math.MinInt32) - 1, + wantErr: true, + }, + { + name: "above max", + val: int64(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestUint32ToInt32 checks that an uint32 value is safely converted to int32 +// only when it fits within the signed 32 bit range. It should fail loudly +// for any value that exceeds those limits. +func TestUint32ToInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want int32 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + {name: "max int32", val: math.MaxInt32, want: math.MaxInt32}, + { + name: "overflow", + val: uint32(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestUint32ToNullInt32 checks that we respect the signed 32 bit limits +// before converting an uint32 value into sql.NullInt32. It should fail +// loudly when the value is out of range or when valid is false. +func TestUint32ToNullInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want sql.NullInt32 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: sql.NullInt32{Int32: 0, Valid: true}, + }, + { + name: "max int32", + val: math.MaxInt32, + want: sql.NullInt32{ + Int32: math.MaxInt32, + Valid: true, + }, + }, + { + name: "overflow", + val: uint32(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToNullInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestNullInt32ToUint32 checks that we convert a sql.NullInt32 to uint32 +// only when the value is marked as valid and fits within the uint32 range. +// It should fail loudly for any out of range or invalid value. +func TestNullInt32ToUint32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val sql.NullInt32 + want uint32 + wantErr error + }{ + { + name: "zero", + val: sql.NullInt32{Int32: 0, Valid: true}, + want: 0, + }, + { + name: "positive", + val: sql.NullInt32{Int32: 42, Valid: true}, + want: 42, + }, + { + name: "negative overflow", + val: sql.NullInt32{Int32: -1, Valid: true}, + wantErr: ErrCastingOverflow, + }, + { + name: "invalid null", + val: sql.NullInt32{Valid: false}, + wantErr: ErrInvalidNullInt, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := nullInt32ToUint32(tc.val) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} From fa4c3150d4d0906ebe958d04cab78d6ded57c382 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 02:16:13 -0300 Subject: [PATCH 015/695] wallet: add database structs --- wallet/internal/db/interface.go | 9 +++++++++ wallet/internal/db/pg.go | 26 ++++++++++++++++++++++++++ wallet/internal/db/sqlite.go | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 wallet/internal/db/pg.go create mode 100644 wallet/internal/db/sqlite.go diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index ee0b249ffb..2c8504db66 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -2,12 +2,21 @@ package db import ( "context" + "errors" "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" ) +var ( + // ErrNilDB is returned when a nil database connection pointer is + // provided to the wallet. + ErrNilDB = errors.New( + "wallet requires a non-nil database connection", + ) +) + // WalletStore defines the methods for wallet-level operations. type WalletStore interface { // CreateWallet creates a new wallet in the database with the provided diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go new file mode 100644 index 0000000000..125aa51f19 --- /dev/null +++ b/wallet/internal/db/pg.go @@ -0,0 +1,26 @@ +package db + +import ( + "database/sql" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// PostgresWalletDB is the PostgreSQL implementation of the +// WalletStore interface. +type PostgresWalletDB struct { + db *sql.DB + queries *sqlcpg.Queries +} + +// NewPostgresWalletDB creates a new PostgreSQL-based WalletStore. +func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { + if db == nil { + return nil, ErrNilDB + } + + return &PostgresWalletDB{ + db: db, + queries: sqlcpg.New(db), + }, nil +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go new file mode 100644 index 0000000000..819ccca4c6 --- /dev/null +++ b/wallet/internal/db/sqlite.go @@ -0,0 +1,25 @@ +package db + +import ( + "database/sql" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// SQLiteWalletDB is the SQLite implementation of the WalletStore interface. +type SQLiteWalletDB struct { + db *sql.DB + queries *sqlcsqlite.Queries +} + +// NewSQLiteWalletDB creates a new SQLite-based WalletStore. +func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { + if db == nil { + return nil, ErrNilDB + } + + return &SQLiteWalletDB{ + db: db, + queries: sqlcsqlite.New(db), + }, nil +} From 7d68a3259ac5fc5817658d04f824e2847d33a461 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 27 Nov 2025 15:11:28 -0300 Subject: [PATCH 016/695] wallet: add database constructors test --- wallet/internal/db/db_connectors_test.go | 116 +++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 wallet/internal/db/db_connectors_test.go diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go new file mode 100644 index 0000000000..48a4bdd47e --- /dev/null +++ b/wallet/internal/db/db_connectors_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "database/sql" + "database/sql/driver" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const testDriverName = "wallet-test-driver" + +var ( + registerDriverOnce sync.Once + testDriver *mockDriver +) + +// newMockedTestDB returns a *sql.DB backed by a mock driver. It avoids any +// network or disk usage, so it works well for constructor tests that only +// need a non nil database handle. It should be used only in very simple +// scenarios, since it does not implement real behavior and cannot confirm +// that the issued queries works as expected. +func newMockedTestDB(t *testing.T) *sql.DB { + t.Helper() + + registerDriverOnce.Do(func() { + testDriver = &mockDriver{} + testDriver.On("Open", mock.Anything).Return(mockConn{}, nil) + + sql.Register(testDriverName, testDriver) + }) + + db, err := sql.Open(testDriverName, "") + require.NoError(t, err) + + t.Cleanup(func() { + _ = db.Close() + }) + + return db +} + +// TestNewPostgresWalletDB checks that the PostgresWalletDB constructor +// properly guards against nil *sql.DB inputs and wires up the queries +// correctly. +func TestNewPostgresWalletDB(t *testing.T) { + t.Parallel() + + t.Run("nil db", func(t *testing.T) { + t.Parallel() + + db, err := NewPostgresWalletDB(nil) + require.ErrorIs(t, err, ErrNilDB) + require.Nil(t, db) + }) + + t.Run("valid db", func(t *testing.T) { + t.Parallel() + + sqlDB := newMockedTestDB(t) + + db, err := NewPostgresWalletDB(sqlDB) + require.NoError(t, err) + require.NotNil(t, db) + require.Equal(t, sqlDB, db.db) + require.NotNil(t, db.queries) + }) +} + +// TestNewSQLiteWalletDB checks that the SQLiteWalletDB constructor +// properly guards against nil *sql.DB inputs and wires up the queries +// correctly. +func TestNewSQLiteWalletDB(t *testing.T) { + t.Parallel() + + t.Run("nil db", func(t *testing.T) { + t.Parallel() + + db, err := NewSQLiteWalletDB(nil) + require.ErrorIs(t, err, ErrNilDB) + require.Nil(t, db) + }) + + t.Run("valid db", func(t *testing.T) { + t.Parallel() + + sqlDB := newMockedTestDB(t) + + db, err := NewSQLiteWalletDB(sqlDB) + require.NoError(t, err) + require.NotNil(t, db) + require.Equal(t, sqlDB, db.db) + require.NotNil(t, db.queries) + }) +} + +// mockDriver implements a bare-bones SQL driver so tests can obtain a *sql.DB +// without depending on an external database. +type mockDriver struct { + mock.Mock +} + +func (m *mockDriver) Open(name string) (driver.Conn, error) { + args := m.Called(name) + conn, _ := args.Get(0).(driver.Conn) + + return conn, args.Error(1) +} + +// mockConn is a mock implementation of a database connection. It does not +// implement any real behavior. Used to be returned by the mockDriver. +type mockConn struct { + mock.Mock +} From bf07c1cc3a19ecdc40a7cfa9e54dd4605a38f423 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:55:34 -0300 Subject: [PATCH 017/695] wallet: add simple migration with golang-migrate --- wallet/internal/db/migrations.go | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 wallet/internal/db/migrations.go diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go new file mode 100644 index 0000000000..cc929d1f35 --- /dev/null +++ b/wallet/internal/db/migrations.go @@ -0,0 +1,84 @@ +package db + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/sqlite/*.sql +var sqliteFS embed.FS + +//go:embed migrations/postgres/*.sql +var postgresFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations is a simple function that applies all migrations found in the +// given migrationFS at the given path to the provided database using the given +// driver factory. +// +// TODO(gustavostingelin): enhance migrations to be like sqldb/v2 before +// production use. This is a simplified migration system suitable for +// integration tests but lacks features required for production: +// - No migration version tracking or status checks +// - No migration history table or audit trail +// - No protection against concurrent migrations +// +// For production use, this should be enhanced to match the patterns in +// lnd/sqldb/v2, which provides a more robust migration framework. +func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, + newDriver driverFactory) error { + + sourceDriver, err := iofs.New(migrationFS, path) + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create %s driver: %w", dbName, err) + } + + m, err := migrate.NewWithInstance("iofs", sourceDriver, dbName, driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplySQLiteMigrations applies all SQLite migrations to the database. +// +// NOTE: not ready for production use. +func ApplySQLiteMigrations(db *sql.DB) error { + return applyMigrations(db, sqliteFS, "migrations/sqlite", "sqlite", + func(db *sql.DB) (database.Driver, error) { + return sqlite.WithInstance(db, &sqlite.Config{}) + }, + ) +} + +// ApplyPostgresMigrations applies all PostgreSQL migrations to the database. +// +// NOTE: not ready for production use. +func ApplyPostgresMigrations(db *sql.DB) error { + return applyMigrations(db, postgresFS, "migrations/postgres", + "postgres", func(db *sql.DB) (database.Driver, error) { + return postgres.WithInstance(db, &postgres.Config{}) + }, + ) +} From 2cc0fbc8831c8049b834a49a2df6760879b2cf72 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:58:48 -0300 Subject: [PATCH 018/695] wallet: add pg and sqlite integration test setup --- wallet/internal/db/itest/pg_test.go | 179 ++++++++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 52 +++++++ 2 files changed, 231 insertions(+) create mode 100644 wallet/internal/db/itest/pg_test.go create mode 100644 wallet/internal/db/itest/sqlite_test.go diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go new file mode 100644 index 0000000000..41065c45b7 --- /dev/null +++ b/wallet/internal/db/itest/pg_test.go @@ -0,0 +1,179 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "context" + "database/sql" + "fmt" + "os" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +var ( + // Shared container instance, reused across tests for performance. + // This is safe to use concurrently because we only share the container + // and not the database inside it. Each test gets its own database. + pgContainer *postgres.PostgresContainer + + // Ensure the container is created only once. + pgContainerOnce sync.Once + + // Error returned by the container creation operation. We need to store + // it to return when the error already occurred during test setup. + pgContainerErr error + + // Timeout for waiting for the postgres container to start. Needs to + // consider container image download time. + pgInitTimeout = 2 * time.Minute + + // Timeout for terminating the postgres container after the test suite. + pgTerminateTimeout = 1 * time.Minute +) + +// TestMain ensures the shared postgres container is terminated after the +// integration test suite completes to avoid leaking docker resources. +func TestMain(m *testing.M) { + code := m.Run() + + // Terminate the container after the test suite completes. + if pgContainer != nil { + ctx, cancel := context.WithTimeout(context.Background(), pgTerminateTimeout) + defer cancel() + + err := pgContainer.Terminate(ctx) + if err != nil { + fmt.Printf("failed to terminate postgres container: %v\n", err) + } + } + + os.Exit(code) +} + +// PostgresConfig holds configuration for the test PostgreSQL database. +type PostgresConfig struct { + // Image is the Docker image to use. + Image string + + // Database is the database name. + Database string + + // Username is the database user. + Username string + + // Password is the database password. + Password string +} + +// DefaultPostgresConfig returns the default PostgreSQL test configuration. +func DefaultPostgresConfig() PostgresConfig { + return PostgresConfig{ + Image: "postgres:18-alpine", + Database: "postgres", + Username: "postgres", + Password: "postgres", + } +} + +// GetPostgresContainer returns the shared PostgreSQL container instance. +// The container is created once and reused across all tests for performance. +// +// Note: postgres:18-alpine defaults max_connections to 100. +func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, error) { + pgContainerOnce.Do(func() { + cfg := DefaultPostgresConfig() + + pgContainer, pgContainerErr = postgres.RunContainer(ctx, + testcontainers.WithImage(cfg.Image), + postgres.WithDatabase(cfg.Database), + postgres.WithUsername(cfg.Username), + postgres.WithPassword(cfg.Password), + testcontainers.WithWaitStrategyAndDeadline( + pgInitTimeout, wait.ForListeningPort("5432/tcp"), + ), + ) + }) + + return pgContainer, pgContainerErr +} + +// sanitizedPgDBName converts a test name to a valid PostgreSQL database name. +// It converts to lowercase and replaces special characters with underscores. +func sanitizedPgDBName(t *testing.T) string { + // Convert to lowercase. + dbName := strings.ToLower(t.Name()) + + // Replace slashes and other special chars with underscores. + reg := regexp.MustCompile(`[^a-z0-9_]`) + dbName = reg.ReplaceAllString(dbName, "_") + + // PostgreSQL database names are limited to 63 characters. + if len(dbName) > 63 { + dbName = dbName[:63] + t.Logf("database name truncated to %d characters: %s", 63, dbName) + } + + return dbName +} + +// NewPostgresDB creates a new PostgreSQL database connection with migrations +// applied. Each test gets its own database for isolation. +func NewPostgresDB(t *testing.T) *sql.DB { + t.Helper() + ctx := t.Context() + + container, err := GetPostgresContainer(ctx) + require.NoError(t, err, "failed to get postgres container") + + connStr, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err, "failed to get connection string") + + // Connect to the default database to create our test database. + adminDB, err := sql.Open("pgx", connStr) + require.NoError(t, err, "failed to open admin connection") + + // Create a database name based on the test name. + dbName := sanitizedPgDBName(t) + + // Create the test database. + createDBStmt := fmt.Sprintf("CREATE DATABASE %s", dbName) + _, err = adminDB.ExecContext(ctx, createDBStmt) + require.NoError(t, err, "failed to create test database") + + // Build the connection string for the test database. + testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) + + // TODO(gustavostingelin): replace with the real PostgreSQL database + // connection constructor when available. + dbConn, err := sql.Open("pgx", testConnStr) + require.NoError(t, err, "failed to open test database connection") + + err = db.ApplyPostgresMigrations(dbConn) + require.NoError(t, err, "failed to apply migrations") + + return dbConn +} + +// NewTestStore creates a PostgreSQL wallet store and returns it along with the +// underlying database connection for tests that also need direct DB access. +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { + t.Helper() + + dbConn := NewPostgresDB(t) + + store, err := db.NewPostgresWalletDB(dbConn) + require.NoError(t, err, "failed to create wallet store") + + return store, dbConn +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go new file mode 100644 index 0000000000..30b6c648a2 --- /dev/null +++ b/wallet/internal/db/itest/sqlite_test.go @@ -0,0 +1,52 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +// NewSQLiteDB creates a new SQLite database for testing with migrations +// applied. Each test gets its own temporary database file. +func NewSQLiteDB(t *testing.T) *sql.DB { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Enable foreign keys (required for proper constraint enforcement). + dsn := dbPath + "?_pragma=foreign_keys=on" + + // TODO(gustavostingelin): replace with the real SQLite database + // connection constructor when available. + dbConn, err := sql.Open("sqlite", dsn) + require.NoError(t, err, "failed to open sqlite database") + + err = db.ApplySQLiteMigrations(dbConn) + require.NoError(t, err, "failed to apply migrations") + + t.Cleanup(func() { + _ = dbConn.Close() + }) + + return dbConn +} + +// NewTestStore creates the SQLite wallet store and returns it along with the +// underlying database connection for tests that also need direct DB access. +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { + t.Helper() + + dbConn := NewSQLiteDB(t) + + store, err := db.NewSQLiteWalletDB(dbConn) + require.NoError(t, err, "failed to create wallet store") + + return store, dbConn +} From b765de611f99fd60e12f957106d49b602ba1b4d9 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:44:23 -0300 Subject: [PATCH 019/695] Makefile + make: add itest cmd --- Makefile | 20 ++++++++++++++++++++ make/testing_flags.mk | 14 ++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Makefile b/Makefile index d4829dd895..c88fb154aa 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,24 @@ unit-bench: @$(call print, "Running benchmark tests.") $(UNIT_BENCH) +#? itest-db: Run integration tests for wallet database +itest-db: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests.") + $(ITEST_DB) + +#? itest-db-race: Run integration tests for wallet database with race detector +itest-db-race: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests (race).") + env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) + # ========= # UTILITIES # ========= @@ -178,6 +196,8 @@ sqlc-check: sqlc unit-race \ unit-debug \ unit-bench \ + itest-db \ + itest-db-race \ fmt \ fmt-check \ tidy-module \ diff --git a/make/testing_flags.mk b/make/testing_flags.mk index a2266c0642..86b9d1e5f8 100644 --- a/make/testing_flags.mk +++ b/make/testing_flags.mk @@ -1,5 +1,16 @@ DEV_TAGS = dev LOG_TAGS = +IT_TAGS ?= + +# Integration test DB selections derived from the `db` variable (sqlite, postgres). +# Defaults to sqlite if not specified. +ifeq ($(db),postgres) +IT_TAGS += test_db_postgres +IT_DB_LABEL := PostgreSQL +else +IT_TAGS := +IT_DB_LABEL := SQLite +endif GOCC ?= go GOLIST := $(GOCC) list -tags="$(DEV_TAGS)" -deps $(PKG)/... | grep '$(PKG)' @@ -75,3 +86,6 @@ UNIT_RACE := $(UNIT) -race endif UNIT_COVER := $(GOTEST) $(COVER_FLAGS) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) $(COVER_PKG) + +ITEST_DB := $(GOTEST) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest +ITEST_DB_RACE := $(GOTEST) -race -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest From 6a6b6fb034f335945a1c3a618e7d8d4355987501 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 9 Dec 2025 14:25:20 -0300 Subject: [PATCH 020/695] CI: add integration test check for pg and sqlite on ubuntu --- .github/workflows/main.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bbdd5257c4..ad81d16d43 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -152,3 +152,40 @@ jobs: with: path-to-profile: coverage.txt parallel: true + + ######################## + # run integration tests (SQLite + Postgres) + ######################## + itest-db: + name: integration tests (${{ matrix.db }}, ${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + db: [sqlite, postgres] + target: [itest-db, itest-db-race] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: go cache + uses: actions/cache@v4 + with: + path: /home/runner/work/go + key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- + btcwallet-${{ runner.os }}-go- + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: run ${{ matrix.db }} ${{ matrix.target }} + run: make ${{ matrix.target }} db=${{ matrix.db }} From e4a16e38085fcc27fb09ba3627dd1b2a767bd139 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 9 Dec 2025 00:56:36 -0300 Subject: [PATCH 021/695] wallet: add database transaction helper --- wallet/internal/db/pg.go | 14 ++++++++++++ wallet/internal/db/sqlite.go | 14 ++++++++++++ wallet/internal/db/tx.go | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 wallet/internal/db/tx.go diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 125aa51f19..1de6f34f9c 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -24,3 +25,16 @@ func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { queries: sqlcpg.New(db), }, nil } + +// ExecuteTx executes a function within a database transaction. The function +// receives a transactional query executor and should perform all database +// operations using it. The transaction will be automatically committed on +// success or rolled back on error. +func (w *PostgresWalletDB) ExecuteTx(ctx context.Context, + fn func(*sqlcpg.Queries) error) error { + + return execInTx(ctx, w.db, func(tx *sql.Tx) error { + qtx := w.queries.WithTx(tx) + return fn(qtx) + }) +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 819ccca4c6..15b8409f8a 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -23,3 +24,16 @@ func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { queries: sqlcsqlite.New(db), }, nil } + +// ExecuteTx executes a function within a database transaction. The function +// receives a transactional query executor and should perform all database +// operations using it. The transaction will be automatically committed on +// success or rolled back on error. +func (w *SQLiteWalletDB) ExecuteTx(ctx context.Context, + fn func(*sqlcsqlite.Queries) error) error { + + return execInTx(ctx, w.db, func(tx *sql.Tx) error { + qtx := w.queries.WithTx(tx) + return fn(qtx) + }) +} diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go new file mode 100644 index 0000000000..da65c17deb --- /dev/null +++ b/wallet/internal/db/tx.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + "database/sql" + "fmt" +) + +// execInTx executes a function within a database transaction. It handles +// the transaction lifecycle: begin, commit, and rollback on error. +// +// This is a helper function used by the public ExecuteTx methods on +// PostgresWalletDB and SQLiteWalletDB. It guarantees that the transaction +// will be either committed (on success) or rolled back (on error or panic). +func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + // Rollback can be called safely even when the transaction is already + // closed. If the transaction commits, this call does nothing. If the + // rollback fails because of a connection issue, it is still fine since + // the transaction was never committed, and the database remains + // unchanged. + defer func() { + _ = tx.Rollback() + }() + + err = fn(tx) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("commit tx: %w", err) + } + + return nil +} From bbfe24b9d3fff620cd77c4806e58b416af401322 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 16 Nov 2025 00:34:39 -0300 Subject: [PATCH 022/695] wallet: add wallets table migration --- .../postgres/000002_wallets.down.sql | 5 + .../migrations/postgres/000002_wallets.up.sql | 108 ++++++++++++++++++ .../migrations/sqlite/000002_wallets.down.sql | 5 + .../migrations/sqlite/000002_wallets.up.sql | 108 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 32 ++++++ wallet/internal/db/sqlc/sqlite/models.go | 32 ++++++ 6 files changed, 290 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000002_wallets.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000002_wallets.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000002_wallets.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.down.sql b/wallet/internal/db/migrations/postgres/000002_wallets.down.sql new file mode 100644 index 0000000000..89cfc08c06 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000002_wallets.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS wallet_sync_states; +DROP TABLE IF EXISTS wallet_secrets; +DROP TABLE IF EXISTS wallets; diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql new file mode 100644 index 0000000000..b53bea515f --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -0,0 +1,108 @@ +-- Wallet metadata and related state tables. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE wallets ( + -- DB ID of the wallet, primary key. Only used for DB level relations. + id BIGSERIAL PRIMARY KEY, + + -- Human friendly name for the wallet. + name TEXT NOT NULL, + + -- Defines if the wallet was imported, all its accounts would also be imported. + is_imported BOOLEAN NOT NULL, + + -- Version of the wallet manager that created this wallet. + manager_version INTEGER NOT NULL, + + -- Defines if the wallet is a watch-only wallet. + is_watch_only BOOLEAN NOT NULL, + + -- Params to derive the public master key. + master_pub_params BYTEA NOT NULL, + + -- Encrypted key used to encrypt/decrypt wallet data related to public keys. + encrypted_crypto_pub_key BYTEA NOT NULL, + + -- Encrypted HD public key of the wallet. NULL for certain wallet types + -- that don't store extended public keys. + encrypted_master_hd_pub_key BYTEA +); + +-- Unique index to prevent duplicate wallet names. +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); + +-- Wallet Secrets table to store rarely accessed, highly sensitive encrypted +-- material with a strict one-to-one relationship with the wallets table. +-- Separated from the main wallets table for security and access pattern isolation. +-- Watch-only wallets may have no corresponding row in this table or have all +-- private key fields with no data. +CREATE TABLE wallet_secrets ( + -- Reference to the wallet these secrets belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id BIGINT NOT NULL, + + -- Params to derive the private master key. NULL for watch-only wallets. + master_priv_params BYTEA, + + -- Encrypted key used to encrypt/decrypt wallet data related to private keys. + -- NULL for watch-only wallets. + encrypted_crypto_priv_key BYTEA, + + -- Encrypted key used to encrypt/decrypt wallet data related to scripts. + -- NULL for watch-only wallets. + encrypted_crypto_script_key BYTEA, + + -- Encrypted HD private key of the wallet. NULL for watch-only wallets. + encrypted_master_hd_priv_key BYTEA, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if secrets still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); + +-- Wallet Sync States table to store the synchronization state of each wallet. +-- This is kept separate from the wallets table to avoid write amplification on +-- frequently updated sync data. Each wallet has exactly one sync state record. +CREATE TABLE wallet_sync_states ( + -- Reference to the wallet this sync state belongs to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id BIGINT NOT NULL, + + -- Current sync status of the wallet (references blocks table). NULL for wallets + -- that haven't synced any blocks yet. + synced_height INTEGER, + + -- Birthday block height of the wallet (references blocks table). NULL if the + -- wallet has no known birthday block. + birthday_height INTEGER, + + -- Indicates if the birthday block has been verified. + birthday_verified BOOLEAN NOT NULL, + + -- Last updated timestamp stored in UTC without timezone info. + updated_at TIMESTAMP NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if sync state still exists. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (synced_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has exactly one sync state record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet + ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql new file mode 100644 index 0000000000..89cfc08c06 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS wallet_sync_states; +DROP TABLE IF EXISTS wallet_secrets; +DROP TABLE IF EXISTS wallets; diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql new file mode 100644 index 0000000000..77582601e5 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -0,0 +1,108 @@ +-- Wallet metadata and related state tables. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE wallets ( + -- DB ID of the wallet, primary key. Only used for DB level relations. + id INTEGER PRIMARY KEY, + + -- Human friendly name for the wallet. + name TEXT NOT NULL, + + -- Defines if the wallet was imported, all its accounts would also be imported. + is_imported BOOLEAN NOT NULL, + + -- Version of the wallet manager that created this wallet. + manager_version INTEGER NOT NULL, + + -- Defines if the wallet is a watch-only wallet. + is_watch_only BOOLEAN NOT NULL, + + -- Params to derive the public master key. + master_pub_params BLOB NOT NULL, + + -- Encrypted key used to encrypt/decrypt wallet data related to public keys. + encrypted_crypto_pub_key BLOB NOT NULL, + + -- Encrypted HD public key of the wallet. NULL for certain wallet types + -- that don't store extended public keys. + encrypted_master_hd_pub_key BLOB +); + +-- Unique index to prevent duplicate wallet names. +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); + +-- Wallet Secrets table to store rarely accessed, highly sensitive encrypted +-- material with a strict one-to-one relationship with the wallets table. +-- Separated from the main wallets table for security and access pattern isolation. +-- Watch-only wallets may have no corresponding row in this table or have all +-- private key fields with no data. +CREATE TABLE wallet_secrets ( + -- Reference to the wallet these secrets belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id INTEGER NOT NULL, + + -- Params to derive the private master key. NULL for watch-only wallets. + master_priv_params BLOB, + + -- Encrypted key used to encrypt/decrypt wallet data related to private keys. + -- NULL for watch-only wallets. + encrypted_crypto_priv_key BLOB, + + -- Encrypted key used to encrypt/decrypt wallet data related to scripts. + -- NULL for watch-only wallets. + encrypted_crypto_script_key BLOB, + + -- Encrypted HD private key of the wallet. NULL for watch-only wallets. + encrypted_master_hd_priv_key BLOB, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if secrets still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); + +-- Wallet Sync States table to store the synchronization state of each wallet. +-- This is kept separate from the wallets table to avoid write amplification on +-- frequently updated sync data. Each wallet has exactly one sync state record. +CREATE TABLE wallet_sync_states ( + -- Reference to the wallet this sync state belongs to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id INTEGER NOT NULL, + + -- Current sync status of the wallet (references blocks table). NULL for wallets + -- that haven't synced any blocks yet. + synced_height INTEGER, + + -- Birthday block height of the wallet (references blocks table). NULL if the + -- wallet has no known birthday block. + birthday_height INTEGER, + + -- Indicates if the birthday block has been verified. + birthday_verified BOOLEAN NOT NULL, + + -- Last updated timestamp stored in UTC without timezone info. + updated_at DATETIME NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if sync state still exists. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (synced_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has exactly one sync state record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet + ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3570205637..a2817f36fe 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -4,8 +4,40 @@ package sqlcpg +import ( + "database/sql" + "time" +) + type Block struct { BlockHeight int32 HeaderHash []byte Timestamp int64 } + +type Wallet struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +type WalletSecret struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +type WalletSyncState struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified bool + UpdatedAt time.Time +} diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 99e67f9744..5a4568589d 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -4,8 +4,40 @@ package sqlcsqlite +import ( + "database/sql" + "time" +) + type Block struct { BlockHeight int64 HeaderHash []byte Timestamp int64 } + +type Wallet struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +type WalletSecret struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +type WalletSyncState struct { + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified bool + UpdatedAt time.Time +} From 5a639d4dfb9350d0c8e93699fd105a83e0351c7f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 17 Nov 2025 13:21:46 -0300 Subject: [PATCH 023/695] wallet: add wallets table queries --- .../internal/db/queries/postgres/wallets.sql | 134 ++++++ wallet/internal/db/queries/sqlite/wallets.sql | 134 ++++++ wallet/internal/db/sqlc/postgres/db.go | 110 ++++- wallet/internal/db/sqlc/postgres/querier.go | 9 + .../internal/db/sqlc/postgres/wallets.sql.go | 398 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 110 ++++- wallet/internal/db/sqlc/sqlite/querier.go | 9 + wallet/internal/db/sqlc/sqlite/wallets.sql.go | 398 ++++++++++++++++++ 8 files changed, 1282 insertions(+), 20 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/wallets.sql create mode 100644 wallet/internal/db/queries/sqlite/wallets.sql create mode 100644 wallet/internal/db/sqlc/postgres/wallets.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/wallets.sql.go diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql new file mode 100644 index 0000000000..bec4bcc149 --- /dev/null +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -0,0 +1,134 @@ +-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) +RETURNING id; + +-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + $1, $2, $3, $4, CURRENT_TIMESTAMP +); + +-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = $1; + +-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = $1; + +-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id; + +-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = $1; + +-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + $1, $2, $3, $4, $5 +); + +-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = $1; + +-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = $1, + encrypted_crypto_priv_key = $2, + encrypted_crypto_script_key = $3, + encrypted_master_hd_priv_key = $4 +WHERE wallet_id = $5; diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql new file mode 100644 index 0000000000..2253bc69d5 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -0,0 +1,134 @@ +-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + ?, ?, ?, ?, CURRENT_TIMESTAMP +); + +-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = sqlc.arg('wallet_id'); + +-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = ?; + +-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id; + +-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = ?; + +-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + ?, ?, ?, ?, ? +); + +-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = ?; + +-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = ?, + encrypted_crypto_priv_key = ?, + encrypted_crypto_script_key = ?, + encrypted_master_hd_priv_key = ? +WHERE wallet_id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 852f9a65f3..57320c40fb 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,20 +24,52 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { + return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) + } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) + } + if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) + } + if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) + } + if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) + } + if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { + return nil, fmt.Errorf("error preparing query ListWallets: %w", err) + } + if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) + } + if q.updateWalletSyncStateStmt, err = db.PrepareContext(ctx, UpdateWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSyncState: %w", err) + } return &q, nil } func (q *Queries) Close() error { var err error + if q.createWalletStmt != nil { + if cerr := q.createWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createWalletStmt: %w", cerr) + } + } if q.deleteBlockStmt != nil { if cerr := q.deleteBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) @@ -48,11 +80,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getWalletByIDStmt != nil { + if cerr := q.getWalletByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) + } + } + if q.getWalletByNameStmt != nil { + if cerr := q.getWalletByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) + } + } + if q.getWalletSecretsStmt != nil { + if cerr := q.getWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertWalletSecretsStmt != nil { + if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) + } + } + if q.insertWalletSyncStateStmt != nil { + if cerr := q.insertWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) + } + } + if q.listWalletsStmt != nil { + if cerr := q.listWalletsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) + } + } + if q.updateWalletSecretsStmt != nil { + if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) + } + } + if q.updateWalletSyncStateStmt != nil { + if cerr := q.updateWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSyncStateStmt: %w", cerr) + } + } return err } @@ -90,19 +162,37 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - deleteBlockStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - insertBlockStmt *sql.Stmt + db DBTX + tx *sql.Tx + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - deleteBlockStmt: q.deleteBlockStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - insertBlockStmt: q.insertBlockStmt, + db: tx, + tx: tx, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 77972c1619..771a6ebdf2 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,9 +9,18 @@ import ( ) type Querier interface { + CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) + GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error + InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + ListWallets(ctx context.Context) ([]ListWalletsRow, error) + UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) + UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go new file mode 100644 index 0000000000..ed8b84fa59 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -0,0 +1,398 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: wallets.sql + +package sqlcpg + +import ( + "context" + "database/sql" +) + +const CreateWallet = `-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) +RETURNING id +` + +type CreateWalletParams struct { + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { + row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, + arg.Name, + arg.IsImported, + arg.ManagerVersion, + arg.IsWatchOnly, + arg.MasterPubParams, + arg.EncryptedCryptoPubKey, + arg.EncryptedMasterHdPubKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetWalletByID = `-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = $1 +` + +type GetWalletByIDRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) { + row := q.queryRow(ctx, q.getWalletByIDStmt, GetWalletByID, id) + var i GetWalletByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletByName = `-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = $1 +` + +type GetWalletByNameRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) + var i GetWalletByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletSecrets = `-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = $1 +` + +func (q *Queries) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) { + row := q.queryRow(ctx, q.getWalletSecretsStmt, GetWalletSecrets, walletID) + var i WalletSecret + err := row.Scan( + &i.WalletID, + &i.MasterPrivParams, + &i.EncryptedCryptoPrivKey, + &i.EncryptedCryptoScriptKey, + &i.EncryptedMasterHdPrivKey, + ) + return i, err +} + +const InsertWalletSecrets = `-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + $1, $2, $3, $4, $5 +) +` + +type InsertWalletSecretsParams struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +func (q *Queries) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error { + _, err := q.exec(ctx, q.insertWalletSecretsStmt, InsertWalletSecrets, + arg.WalletID, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + ) + return err +} + +const InsertWalletSyncState = `-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + $1, $2, $3, $4, CURRENT_TIMESTAMP +) +` + +type InsertWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified bool +} + +func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { + _, err := q.exec(ctx, q.insertWalletSyncStateStmt, InsertWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + return err +} + +const ListWallets = `-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id +` + +type ListWalletsRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWalletsRow + for rows.Next() { + var i ListWalletsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateWalletSecrets = `-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = $1, + encrypted_crypto_priv_key = $2, + encrypted_crypto_script_key = $3, + encrypted_master_hd_priv_key = $4 +WHERE wallet_id = $5 +` + +type UpdateWalletSecretsParams struct { + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte + WalletID int64 +} + +func (q *Queries) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSecretsStmt, UpdateWalletSecrets, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE($2, synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE($3, birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE($4, birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = $1 +` + +type UpdateWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool +} + +func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 44f87a934f..c7080a445a 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,20 +24,52 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { + return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) + } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) + } + if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) + } + if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) + } + if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) + } + if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { + return nil, fmt.Errorf("error preparing query ListWallets: %w", err) + } + if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) + } + if q.updateWalletSyncStateStmt, err = db.PrepareContext(ctx, UpdateWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSyncState: %w", err) + } return &q, nil } func (q *Queries) Close() error { var err error + if q.createWalletStmt != nil { + if cerr := q.createWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createWalletStmt: %w", cerr) + } + } if q.deleteBlockStmt != nil { if cerr := q.deleteBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) @@ -48,11 +80,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getWalletByIDStmt != nil { + if cerr := q.getWalletByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) + } + } + if q.getWalletByNameStmt != nil { + if cerr := q.getWalletByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) + } + } + if q.getWalletSecretsStmt != nil { + if cerr := q.getWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertWalletSecretsStmt != nil { + if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) + } + } + if q.insertWalletSyncStateStmt != nil { + if cerr := q.insertWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) + } + } + if q.listWalletsStmt != nil { + if cerr := q.listWalletsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) + } + } + if q.updateWalletSecretsStmt != nil { + if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) + } + } + if q.updateWalletSyncStateStmt != nil { + if cerr := q.updateWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSyncStateStmt: %w", cerr) + } + } return err } @@ -90,19 +162,37 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - deleteBlockStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - insertBlockStmt *sql.Stmt + db DBTX + tx *sql.Tx + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - deleteBlockStmt: q.deleteBlockStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - insertBlockStmt: q.insertBlockStmt, + db: tx, + tx: tx, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 8f91c3cb17..61cc0cbcfb 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,9 +9,18 @@ import ( ) type Querier interface { + CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) + GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error + InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + ListWallets(ctx context.Context) ([]ListWalletsRow, error) + UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) + UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go new file mode 100644 index 0000000000..289896088a --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -0,0 +1,398 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: wallets.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" +) + +const CreateWallet = `-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type CreateWalletParams struct { + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { + row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, + arg.Name, + arg.IsImported, + arg.ManagerVersion, + arg.IsWatchOnly, + arg.MasterPubParams, + arg.EncryptedCryptoPubKey, + arg.EncryptedMasterHdPubKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetWalletByID = `-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = ? +` + +type GetWalletByIDRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) { + row := q.queryRow(ctx, q.getWalletByIDStmt, GetWalletByID, id) + var i GetWalletByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletByName = `-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = ? +` + +type GetWalletByNameRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) + var i GetWalletByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletSecrets = `-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = ? +` + +func (q *Queries) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) { + row := q.queryRow(ctx, q.getWalletSecretsStmt, GetWalletSecrets, walletID) + var i WalletSecret + err := row.Scan( + &i.WalletID, + &i.MasterPrivParams, + &i.EncryptedCryptoPrivKey, + &i.EncryptedCryptoScriptKey, + &i.EncryptedMasterHdPrivKey, + ) + return i, err +} + +const InsertWalletSecrets = `-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + ?, ?, ?, ?, ? +) +` + +type InsertWalletSecretsParams struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +func (q *Queries) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error { + _, err := q.exec(ctx, q.insertWalletSecretsStmt, InsertWalletSecrets, + arg.WalletID, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + ) + return err +} + +const InsertWalletSyncState = `-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + ?, ?, ?, ?, CURRENT_TIMESTAMP +) +` + +type InsertWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified bool +} + +func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { + _, err := q.exec(ctx, q.insertWalletSyncStateStmt, InsertWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + return err +} + +const ListWallets = `-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id +` + +type ListWalletsRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWalletsRow + for rows.Next() { + var i ListWalletsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateWalletSecrets = `-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = ?, + encrypted_crypto_priv_key = ?, + encrypted_crypto_script_key = ?, + encrypted_master_hd_priv_key = ? +WHERE wallet_id = ? +` + +type UpdateWalletSecretsParams struct { + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte + WalletID int64 +} + +func (q *Queries) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSecretsStmt, UpdateWalletSecrets, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(?1, synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(?2, birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(?3, birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = ?4 +` + +type UpdateWalletSyncStateParams struct { + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + WalletID int64 +} + +func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} From d9bd945bc52a15c49d47bbb80bcd914db97a713b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 02:16:39 -0300 Subject: [PATCH 024/695] wallet: add WalletStore implementation --- wallet/internal/db/block_common.go | 23 ++ wallet/internal/db/block_pg.go | 19 ++ wallet/internal/db/block_sqlite.go | 19 ++ wallet/internal/db/interface.go | 8 + wallet/internal/db/wallet_pg.go | 335 ++++++++++++++++++++++++++++ wallet/internal/db/wallet_sqlite.go | 334 +++++++++++++++++++++++++++ 6 files changed, 738 insertions(+) create mode 100644 wallet/internal/db/block_common.go create mode 100644 wallet/internal/db/block_pg.go create mode 100644 wallet/internal/db/block_sqlite.go create mode 100644 wallet/internal/db/wallet_pg.go create mode 100644 wallet/internal/db/wallet_sqlite.go diff --git a/wallet/internal/db/block_common.go b/wallet/internal/db/block_common.go new file mode 100644 index 0000000000..0aafd3fb35 --- /dev/null +++ b/wallet/internal/db/block_common.go @@ -0,0 +1,23 @@ +package db + +import ( + "fmt" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" +) + +// buildBlock constructs a Block from the provided components that are common +// across different database backends. +func buildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { + h, err := chainhash.NewHash(hash) + if err != nil { + return nil, fmt.Errorf("block hash: %w", err) + } + + return &Block{ + Hash: *h, + Height: height, + Timestamp: time.Unix(timestamp, 0), + }, nil +} diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go new file mode 100644 index 0000000000..2cd05f3608 --- /dev/null +++ b/wallet/internal/db/block_pg.go @@ -0,0 +1,19 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// buildPgBlock constructs a Block from the given PostgreSQL block +// fields. +func buildPgBlock(height sql.NullInt32, hash []byte, + timestamp sql.NullInt64) (*Block, error) { + + height32, err := nullInt32ToUint32(height) + if err != nil { + return nil, fmt.Errorf("block height: %w", err) + } + + return buildBlock(hash, height32, timestamp.Int64) +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go new file mode 100644 index 0000000000..7df790749c --- /dev/null +++ b/wallet/internal/db/block_sqlite.go @@ -0,0 +1,19 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// buildSqliteBlock constructs a Block from the given SQLite block +// fields. +func buildSqliteBlock(height sql.NullInt64, hash []byte, + timestamp sql.NullInt64) (*Block, error) { + + height32, err := int64ToUint32(height.Int64) + if err != nil { + return nil, fmt.Errorf("block height: %w", err) + } + + return buildBlock(hash, height32, timestamp.Int64) +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 2c8504db66..1a88cc6130 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -10,6 +10,14 @@ import ( ) var ( + // ErrWalletNotFound is returned when a wallet is not found in the + // database. + ErrWalletNotFound = errors.New("wallet not found") + + // ErrSecretNotFound is returned when a secret is not found or is empty + // in the database. + ErrSecretNotFound = errors.New("secret not found") + // ErrNilDB is returned when a nil database connection pointer is // provided to the wallet. ErrNilDB = errors.New( diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go new file mode 100644 index 0000000000..4658c15e74 --- /dev/null +++ b/wallet/internal/db/wallet_pg.go @@ -0,0 +1,335 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Ensure PostgresWalletDB satisfies the WalletStore interface. +var _ WalletStore = (*PostgresWalletDB)(nil) + +// CreateWallet creates a new wallet in the database with the provided +// parameters. It returns the created wallet info or an error if the +// creation fails. +func (w *PostgresWalletDB) CreateWallet(ctx context.Context, + params CreateWalletParams) (*WalletInfo, error) { + + var info *WalletInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + walletParams := sqlcpg.CreateWalletParams{ + Name: params.Name, + IsImported: params.IsImported, + ManagerVersion: params.ManagerVersion, + IsWatchOnly: params.IsWatchOnly, + MasterPubParams: params.MasterKeyPubParams, + EncryptedCryptoPubKey: params.EncryptedCryptoPubKey, + EncryptedMasterHdPubKey: params.EncryptedMasterPubKey, + } + + id, err := qtx.CreateWallet(ctx, walletParams) + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + + secretsParams := sqlcpg.InsertWalletSecretsParams{ + WalletID: id, + MasterPrivParams: params.MasterKeyPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params. + EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterPrivKey, + } + + err = qtx.InsertWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf( + "insert wallet secrets: %w", err, + ) + } + + syncParams := sqlcpg.InsertWalletSyncStateParams{ + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + BirthdayVerified: false, + } + + err = qtx.InsertWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf( + "upsert wallet sync state: %w", err, + ) + } + + row, err := qtx.GetWalletByID(ctx, id) + if err != nil { + return fmt.Errorf( + "fetch created wallet: %w", err, + ) + } + + info, err = buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return fmt.Errorf("convert wallet row to info: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// GetWallet retrieves information about a wallet given its name. It +// returns a WalletInfo struct containing the wallet's properties or an +// error if the wallet is not found. +func (w *PostgresWalletDB) GetWallet(ctx context.Context, + name string) (*WalletInfo, error) { + + row, err := w.queries.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("wallet %q: %w", name, + ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet: %w", err) + } + + return buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// ListWallets returns a slice of WalletInfo for all wallets stored in +// the database. It returns an empty slice if no wallets are found, or +// an error if the retrieval fails. +func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, + error) { + + rows, err := w.queries.ListWallets(ctx) + if err != nil { + return nil, fmt.Errorf("list wallets: %w", err) + } + + wallets := make([]WalletInfo, len(rows)) + for i, row := range rows { + info, err := buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return nil, err + } + + wallets[i] = *info + } + + return wallets, nil +} + +// UpdateWallet updates various properties of a wallet, such as its +// birthday, birthday block, or sync state. The specific fields to +// update are provided in the UpdateWalletParams struct. It returns an +// error if the update fails. +func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, + params UpdateWalletParams) error { + + return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + // Build sync state update params directly from input. + // Only set fields that are being updated (leave others as nil). + syncParams := sqlcpg.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncedHeight, err := uint32ToNullInt32( + params.SyncedTo.Height) + if err != nil { + return err + } + + syncParams.SyncedHeight = syncedHeight + } + + if params.BirthdayBlock != nil { + birthdayHeight, err := uint32ToNullInt32( + params.BirthdayBlock.Height) + if err != nil { + return err + } + + syncParams.BirthdayHeight = birthdayHeight + + // Set birthday verified flag to true if the birthday + // block is set. + syncParams.BirthdayVerified = sql.NullBool{ + Bool: true, + Valid: true, + } + } + + rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf("update wallet sync state: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet sync state for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil + }) +} + +// GetEncryptedHDSeed retrieves the encrypted Hierarchical +// Deterministic (HD) seed of the wallet. This seed is sensitive +// information and is returned in its encrypted form. It returns the +// encrypted seed as a byte slice or an error if the retrieval fails. +func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, + walletID uint32) ([]byte, error) { + + secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("secrets for wallet %d: %w", + walletID, ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet secrets: %w", err) + } + + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return nil, fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + ErrSecretNotFound) + } + + return secrets.EncryptedMasterHdPrivKey, nil +} + +// UpdateWalletSecrets updates the secrets for the wallet. +func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error { + + secretsParams := sqlcpg.UpdateWalletSecretsParams{ + MasterPrivParams: params.MasterPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterHdPrivKey, + WalletID: int64(params.WalletID), + } + + rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil +} + +// pgWalletRowParams holds the parameters needed to build a WalletInfo +// from a wallet row. +type pgWalletRowParams struct { + id int64 + name string + isImported bool + managerVersion int32 + isWatchOnly bool + syncedHeight sql.NullInt32 + syncedBlockHash []byte + syncedBlockTimestamp sql.NullInt64 + birthdayHeight sql.NullInt32 + birthdayBlockHash []byte + birthdayBlockTimestamp sql.NullInt64 +} + +// buildPgWalletInfo constructs a WalletInfo from the given wallet row +// parameters. +func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { + walletID, err := int64ToUint32(row.id) + if err != nil { + return nil, err + } + + info := &WalletInfo{ + ID: walletID, + Name: row.name, + IsImported: row.isImported, + ManagerVersion: row.managerVersion, + IsWatchOnly: row.isWatchOnly, + } + + if row.syncedHeight.Valid { + block, err := buildPgBlock( + row.syncedHeight, + row.syncedBlockHash, + row.syncedBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("synced block: %w", err) + } + + info.SyncedTo = block + } + + if row.birthdayHeight.Valid { + block, err := buildPgBlock( + row.birthdayHeight, + row.birthdayBlockHash, + row.birthdayBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("birthday block: %w", err) + } + + info.BirthdayBlock = *block + info.Birthday = block.Timestamp + } + + return info, nil +} diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go new file mode 100644 index 0000000000..ffed6a16f8 --- /dev/null +++ b/wallet/internal/db/wallet_sqlite.go @@ -0,0 +1,334 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Ensure SQLiteWalletDB satisfies the WalletStore interface. +var _ WalletStore = (*SQLiteWalletDB)(nil) + +// CreateWallet creates a new wallet in the database with the provided +// parameters. It returns the created wallet info or an error if the +// creation fails. +func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, + params CreateWalletParams) (*WalletInfo, error) { + + var info *WalletInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + walletParams := sqlcsqlite.CreateWalletParams{ + Name: params.Name, + IsImported: params.IsImported, + ManagerVersion: int64(params.ManagerVersion), + IsWatchOnly: params.IsWatchOnly, + MasterPubParams: params.MasterKeyPubParams, + EncryptedCryptoPubKey: params.EncryptedCryptoPubKey, + EncryptedMasterHdPubKey: params.EncryptedMasterPubKey, + } + + id, err := qtx.CreateWallet(ctx, walletParams) + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + + secretsParams := sqlcsqlite.InsertWalletSecretsParams{ + WalletID: id, + MasterPrivParams: params.MasterKeyPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params. + EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterPrivKey, + } + + err = qtx.InsertWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf( + "insert wallet secrets: %w", err, + ) + } + + syncParams := sqlcsqlite.InsertWalletSyncStateParams{ + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + BirthdayVerified: false, + } + + err = qtx.InsertWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf( + "upsert wallet sync state: %w", err, + ) + } + + row, err := qtx.GetWalletByID(ctx, id) + if err != nil { + return fmt.Errorf( + "fetch created wallet: %w", err, + ) + } + + info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return fmt.Errorf("convert wallet row to info: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// GetWallet retrieves information about a wallet given its name. It +// returns a WalletInfo struct containing the wallet's properties or an +// error if the wallet is not found. +func (w *SQLiteWalletDB) GetWallet(ctx context.Context, + name string) (*WalletInfo, error) { + + row, err := w.queries.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("wallet %q: %w", name, + ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet: %w", err) + } + + return buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// ListWallets returns a slice of WalletInfo for all wallets stored in +// the database. It returns an empty slice if no wallets are found, or +// an error if the retrieval fails. +func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, + error) { + + rows, err := w.queries.ListWallets(ctx) + if err != nil { + return nil, fmt.Errorf("list wallets: %w", err) + } + + wallets := make([]WalletInfo, len(rows)) + for i, row := range rows { + info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return nil, err + } + + wallets[i] = *info + } + + return wallets, nil +} + +// UpdateWallet updates various properties of a wallet, such as its +// birthday, birthday block, or sync state. The specific fields to +// update are provided in the UpdateWalletParams struct. It returns an +// error if the update fails. +func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, + params UpdateWalletParams) error { + + return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + // Build sync state update params directly from input. + // Only set fields that are being updated (leave others as nil). + syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncParams.SyncedHeight = sql.NullInt64{ + Int64: int64(params.SyncedTo.Height), + Valid: true, + } + } + + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), + Valid: true, + } + + // Set birthday verified flag to true if the birthday + // block is set. + syncParams.BirthdayVerified = sql.NullBool{ + Bool: true, + Valid: true, + } + } + + rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf("update wallet sync state: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet sync state for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil + }) +} + +// GetEncryptedHDSeed retrieves the encrypted Hierarchical +// Deterministic (HD) seed of the wallet. This seed is sensitive +// information and is returned in its encrypted form. It returns the +// encrypted seed as a byte slice or an error if the retrieval fails. +func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, + walletID uint32) ([]byte, error) { + + secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("secrets for wallet %d: %w", + walletID, ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet secrets: %w", err) + } + + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return nil, fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + ErrSecretNotFound) + } + + return secrets.EncryptedMasterHdPrivKey, nil +} + +// UpdateWalletSecrets updates the secrets for the wallet. +func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error { + + secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ + MasterPrivParams: params.MasterPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterHdPrivKey, + WalletID: int64(params.WalletID), + } + + rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil +} + +// sqliteWalletRowParams holds the parameters needed to build a +// WalletInfo from a wallet row. +type sqliteWalletRowParams struct { + id int64 + name string + isImported bool + managerVersion int64 + isWatchOnly bool + syncedHeight sql.NullInt64 + syncedBlockHash []byte + syncedBlockTimestamp sql.NullInt64 + birthdayHeight sql.NullInt64 + birthdayBlockHash []byte + birthdayBlockTimestamp sql.NullInt64 +} + +// buildSqliteWalletInfo constructs a WalletInfo from the given wallet +// row parameters. +func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { + walletID, err := int64ToUint32(row.id) + if err != nil { + return nil, err + } + + mgrVer, err := int64ToInt32(row.managerVersion) + if err != nil { + return nil, err + } + + info := &WalletInfo{ + ID: walletID, + Name: row.name, + IsImported: row.isImported, + ManagerVersion: mgrVer, + IsWatchOnly: row.isWatchOnly, + } + + if row.syncedHeight.Valid { + block, err := buildSqliteBlock( + row.syncedHeight, + row.syncedBlockHash, + row.syncedBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("synced block: %w", err) + } + + info.SyncedTo = block + } + + if row.birthdayHeight.Valid { + block, err := buildSqliteBlock( + row.birthdayHeight, + row.birthdayBlockHash, + row.birthdayBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("birthday block: %w", err) + } + + info.BirthdayBlock = *block + info.Birthday = block.Timestamp + } + + return info, nil +} From e9223af2f61b53e084485ac276de693d3cc964df Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:44:07 -0300 Subject: [PATCH 025/695] wallet: add WalletStore integration tests --- wallet/internal/db/itest/fixtures_test.go | 100 ++++++ wallet/internal/db/itest/wallet_store_test.go | 301 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 wallet/internal/db/itest/fixtures_test.go create mode 100644 wallet/internal/db/itest/wallet_store_test.go diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go new file mode 100644 index 0000000000..2fa1fb6c83 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_test.go @@ -0,0 +1,100 @@ +package itest + +import ( + "crypto/rand" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// CreateWalletParamsFixture creates test parameters for wallet creation. +func CreateWalletParamsFixture(name string) db.CreateWalletParams { + return db.CreateWalletParams{ + Name: name, + IsImported: false, + ManagerVersion: 1, + IsWatchOnly: false, + EncryptedMasterPrivKey: RandomBytes(32), + EncryptedMasterPubKey: RandomBytes(32), + MasterKeyPubParams: RandomBytes(16), + MasterKeyPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoPubKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + } +} + +// CreateImportedWalletParams creates test parameters for an imported wallet. +func CreateImportedWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsImported = true + + return params +} + +// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. +func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsWatchOnly = true + params.EncryptedMasterPrivKey = nil + params.MasterKeyPrivParams = nil + params.EncryptedCryptoPrivKey = nil + params.EncryptedCryptoScriptKey = nil + + return params +} + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { + t.Helper() + + hash := RandomHash() + timestamp := time.Now().UTC() + + // TODO(gustavostingelin): use the block store to insert the block when + // available. + query := ` + INSERT INTO blocks (block_height, header_hash, timestamp) + VALUES ($1, $2, $3) + ` + _, err := dbConn.ExecContext(t.Context(), query, + height, hash[:], timestamp.Unix()) + require.NoError(t, err, "failed to insert block") + + return db.Block{ + Hash: hash, + Height: height, + Timestamp: timestamp, + } +} + +// RandomBytes generates random bytes for test data. +func RandomBytes(n int) []byte { + b := make([]byte, n) + + _, err := rand.Read(b) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random bytes: %v", err)) + } + + return b +} + +// RandomHash generates a random chainhash.Hash for testing. +func RandomHash() chainhash.Hash { + var h chainhash.Hash + + _, err := rand.Read(h[:]) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random hash: %v", err)) + } + + return h +} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go new file mode 100644 index 0000000000..e19881198d --- /dev/null +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -0,0 +1,301 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateWallet verifies that CreateWallet correctly creates a wallet +// and returns its information. +func TestCreateWallet(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := CreateWalletParamsFixture("test-wallet") + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + + require.Equal(t, info.ID, uint32(1), "first wallet ID should be 1") + require.Equal(t, params.Name, info.Name) + require.Equal(t, params.IsImported, info.IsImported) + require.Equal(t, params.ManagerVersion, info.ManagerVersion) + require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) + + require.Nil(t, info.SyncedTo) + require.Equal(t, uint32(0), info.BirthdayBlock.Height) +} + +// TestCreateWallet_DuplicateName verifies that creating a wallet with a +// duplicate name fails with an appropriate error. +func TestCreateWallet_DuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := CreateWalletParamsFixture("duplicate-wallet") + + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second wallet with same name. + _, err = store.CreateWallet(t.Context(), params) + require.Error(t, err, "expected error creating duplicate wallet") + + // We still do not normalize this error across database backends, + // and each engine returns its own message. Because of that, + // we only check for the shared parts of the message here. + require.ErrorContains(t, err, "wallets") + require.ErrorContains(t, err, "name") + require.ErrorContains(t, err, "constraint") +} + +// TestCreateWallet_Variants tests different wallet types. +func TestCreateWallet_Variants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params func(string) db.CreateWalletParams + }{ + { + name: "imported wallet", + params: CreateImportedWalletParams, + }, + { + name: "watch-only wallet", + params: CreateWatchOnlyWalletParams, + }, + { + name: "standard wallet", + params: CreateWalletParamsFixture, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := tc.params(tc.name) + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + require.Equal(t, params.IsImported, info.IsImported) + require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) + }) + } +} + +// TestGetWallet verifies that GetWallet retrieves a wallet by name. +func TestGetWallet(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("get-test-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), params.Name) + require.NoError(t, err) + require.NotNil(t, retrieved) + + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) +} + +// TestGetWallet_NotFound verifies that GetWallet returns ErrWalletNotFound +// when the wallet doesn't exist. +func TestGetWallet_NotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + _, err := store.GetWallet(t.Context(), "non-existent-wallet") + require.Error(t, err) + require.ErrorIs(t, err, db.ErrWalletNotFound) +} + +// TestListWallets verifies that ListWallets returns all wallets. +func TestListWallets(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + // Initially empty. + wallets, err := store.ListWallets(t.Context()) + require.NoError(t, err) + require.Empty(t, wallets) + + // Create three wallets. + names := []string{"wallet-1", "wallet-2", "wallet-3"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + wallets, err = store.ListWallets(t.Context()) + require.NoError(t, err) + require.Len(t, wallets, 3) + + // Verify all names are present. + walletsName := make([]string, len(wallets)) + for i, w := range wallets { + walletsName[i] = w.Name + } + require.ElementsMatch(t, names, walletsName) +} + +// TestUpdateWallet_SyncedTo checks that updating the wallet's synced-to block +// works correctly. +func TestUpdateWallet_SyncedTo(t *testing.T) { + t.Parallel() + + store, dbConn := NewTestStore(t) + + params := CreateWalletParamsFixture("update-sync-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + block := CreateBlockFixture(t, dbConn, 100) + + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + SyncedTo: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Equal(t, created.BirthdayBlock.Height, + retrieved.BirthdayBlock.Height) +} + +// TestUpdateWallet_BirthdayBlock checks that updating the wallet's birthday +// block works correctly. +func TestUpdateWallet_BirthdayBlock(t *testing.T) { + t.Parallel() + + store, dbConn := NewTestStore(t) + + params := CreateWalletParamsFixture("update-birthday-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + block := CreateBlockFixture(t, dbConn, 50) + + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + BirthdayBlock: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.Equal(t, block.Height, retrieved.BirthdayBlock.Height) + require.Equal(t, block.Hash, retrieved.BirthdayBlock.Hash) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.SyncedTo) +} + +// TestUpdateWallet_NotFound verifies that updating a non-existent wallet fails. +func TestUpdateWallet_NotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + updateParams := db.UpdateWalletParams{ + WalletID: 99999, // Non-existent ID. + } + + err := store.UpdateWallet(t.Context(), updateParams) + require.Error(t, err) + require.ErrorIs(t, err, db.ErrWalletNotFound) +} + +// TestGetEncryptedHDSeed verifies retrieving the encrypted HD seed. +func TestGetEncryptedHDSeed(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("seed-wallet") + expectedSeed := params.EncryptedMasterPrivKey + + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.NoError(t, err) + require.Equal(t, expectedSeed, seed) +} + +// TestGetEncryptedHDSeed_WatchOnly verifies that watch-only wallets +// have no encrypted seed. +func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWatchOnlyWalletParams("watch-only-seed") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.Nil(t, seed, "watch-only wallets should not have HD seed") + require.ErrorIs(t, err, db.ErrSecretNotFound) +} + +// TestUpdateWalletSecrets checks that updating the wallet secrets works +// correctly. +func TestUpdateWalletSecrets(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("secrets-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + newSecrets := db.UpdateWalletSecretsParams{ + WalletID: created.ID, + MasterPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + EncryptedMasterHdPrivKey: RandomBytes(32), + } + + err = store.UpdateWalletSecrets(t.Context(), newSecrets) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.NoError(t, err) + require.Equal(t, newSecrets.EncryptedMasterHdPrivKey, seed) +} From 8c800c5a96bd876d9dc39f1baf1e7efbe83e494f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 11 Dec 2025 00:23:41 -0300 Subject: [PATCH 026/695] wallet: add user-provided birthday to wallet sql store --- wallet/internal/db/data_types.go | 23 +++++-- wallet/internal/db/itest/wallet_store_test.go | 67 +++++++++++++++++-- .../migrations/postgres/000002_wallets.up.sql | 7 +- .../migrations/sqlite/000002_wallets.up.sql | 7 +- .../internal/db/queries/postgres/wallets.sql | 12 ++-- wallet/internal/db/queries/sqlite/wallets.sql | 12 ++-- wallet/internal/db/sqlc/postgres/models.go | 10 +-- .../internal/db/sqlc/postgres/wallets.sql.go | 44 ++++++------ wallet/internal/db/sqlc/sqlite/models.go | 10 +-- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 44 ++++++------ wallet/internal/db/wallet_pg.go | 41 ++++++++---- wallet/internal/db/wallet_sqlite.go | 41 ++++++++---- 12 files changed, 209 insertions(+), 109 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 4b9ef87df7..4abd41d381 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -168,13 +168,15 @@ type WalletInfo struct { // meaning it does not have private keys and cannot sign transactions. IsWatchOnly bool - // Birthday is the timestamp of the wallet's creation, used as a - // starting point for rescans. + // Birthday is the user-provided timestamp for when to start rescanning. + // This is stored directly in the database and may be zero if not set. + // If zero, means the wallet should be rescanned from the genesis block. Birthday time.Time - // BirthdayBlock is the block hash and height from which to start a - // rescan. - BirthdayBlock Block + // BirthdayBlock is the verified block reference for starting a rescan. + // When this is non-nil, it indicates the block has been verified. + // A nil value means the birthday block has not been set or verified. + BirthdayBlock *Block // SyncedTo represents the wallet's current synchronization state with // the blockchain. @@ -212,6 +214,11 @@ type CreateWalletParams struct { // watch-only mode. IsWatchOnly bool + // Birthday is the user-provided birthday timestamp for the wallet. + // The zero value is treated as "no birthday" and is stored as NULL in + // the database. + Birthday time.Time + // EncryptedMasterPrivKey is the encrypted master HD private key. EncryptedMasterPrivKey []byte @@ -248,10 +255,12 @@ type UpdateWalletParams struct { // databases (signed 64-bit integers). WalletID uint32 - // Birthday is the new birthday for the wallet. + // Birthday is the user-provided birthday timestamp for the wallet. + // Setting this does NOT set BirthdayBlock. Birthday *time.Time - // BirthdayBlock is the new birthday block for the wallet. + // BirthdayBlock is the verified birthday block for the wallet. + // When this is set, it indicates the block is already verified. BirthdayBlock *Block // SyncedTo is the new synchronization state for the wallet. diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index e19881198d..0af162cc41 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -4,6 +4,7 @@ package itest import ( "testing" + "time" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" @@ -16,7 +17,6 @@ func TestCreateWallet(t *testing.T) { store, _ := NewTestStore(t) params := CreateWalletParamsFixture("test-wallet") - info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) require.NotNil(t, info) @@ -28,7 +28,27 @@ func TestCreateWallet(t *testing.T) { require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) require.Nil(t, info.SyncedTo) - require.Equal(t, uint32(0), info.BirthdayBlock.Height) + require.Nil(t, info.BirthdayBlock) + require.True(t, info.Birthday.IsZero()) +} + +// TestCreateWallet_WithBirthday checks that CreateWallet correctly sets the +// wallet's birthday timestamp. +func TestCreateWallet_WithBirthday(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("birthday-wallet") + birthday := time.Now().UTC().Add(-30 * 24 * time.Hour) + params.Birthday = birthday + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + + require.Equal(t, birthday.Unix(), info.Birthday.Unix()) + require.Nil(t, info.BirthdayBlock) } // TestCreateWallet_DuplicateName verifies that creating a wallet with a @@ -187,8 +207,7 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { require.Equal(t, created.IsImported, retrieved.IsImported) require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) - require.Equal(t, created.BirthdayBlock.Height, - retrieved.BirthdayBlock.Height) + require.Nil(t, retrieved.BirthdayBlock) } // TestUpdateWallet_BirthdayBlock checks that updating the wallet's birthday @@ -202,6 +221,9 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { created, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) + // Initially, BirthdayBlock should be nil. + require.Nil(t, created.BirthdayBlock) + block := CreateBlockFixture(t, dbConn, 50) updateParams := db.UpdateWalletParams{ @@ -213,8 +235,44 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { retrieved, err := store.GetWallet(t.Context(), created.Name) require.NoError(t, err) + require.NotNil(t, retrieved.BirthdayBlock) require.Equal(t, block.Height, retrieved.BirthdayBlock.Height) require.Equal(t, block.Hash, retrieved.BirthdayBlock.Hash) + require.Equal(t, block.Timestamp.Unix(), + retrieved.BirthdayBlock.Timestamp.Unix()) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.SyncedTo) +} + +// TestUpdateWallet_Birthday checks that updating the wallet's birthday +// timestamp works correctly. +func TestUpdateWallet_Birthday(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("birthday-timestamp-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Set birthday timestamp without setting birthday block. + birthdayTime := time.Now().UTC().Add(-30 * 24 * time.Hour) + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + Birthday: &birthdayTime, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.Equal(t, birthdayTime.Unix(), retrieved.Birthday.Unix()) // Assert fields that were not updated remain unchanged. require.Equal(t, created.ID, retrieved.ID) @@ -222,6 +280,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { require.Equal(t, created.IsImported, retrieved.IsImported) require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.BirthdayBlock) require.Nil(t, retrieved.SyncedTo) } diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index b53bea515f..6bf74e4a06 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -78,11 +78,12 @@ CREATE TABLE wallet_sync_states ( synced_height INTEGER, -- Birthday block height of the wallet (references blocks table). NULL if the - -- wallet has no known birthday block. + -- wallet has no known birthday block. When set, indicates the block has been + -- verified. birthday_height INTEGER, - -- Indicates if the birthday block has been verified. - birthday_verified BOOLEAN NOT NULL, + -- User-provided birthday timestamp for wallet rescan. NULL if not set. + birthday TIMESTAMP, -- Last updated timestamp stored in UTC without timezone info. updated_at TIMESTAMP NOT NULL, diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 77582601e5..eebcd592c3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -78,11 +78,12 @@ CREATE TABLE wallet_sync_states ( synced_height INTEGER, -- Birthday block height of the wallet (references blocks table). NULL if the - -- wallet has no known birthday block. + -- wallet has no known birthday block. When set, indicates the block has been + -- verified. birthday_height INTEGER, - -- Indicates if the birthday block has been verified. - birthday_verified BOOLEAN NOT NULL, + -- User-provided birthday timestamp for wallet rescan. NULL if not set. + birthday DATETIME, -- Last updated timestamp stored in UTC without timezone info. updated_at DATETIME NOT NULL, diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index bec4bcc149..248f3b98fe 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( $1, $2, $3, $4, CURRENT_TIMESTAMP @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 2253bc69d5..0259e8801d 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( ?, ?, ?, ?, CURRENT_TIMESTAMP @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index a2817f36fe..9f49ab37e5 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified bool - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index ed8b84fa59..408f4c982b 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletBy &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( $1, $2, $3, $4, CURRENT_TIMESTAMP @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified bool + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE($3, birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE($4, birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE($4, birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -378,10 +378,10 @@ WHERE ` type UpdateWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { @@ -389,7 +389,7 @@ func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) if err != nil { return 0, err diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 5a4568589d..60113bdff6 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified bool - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 289896088a..ae878ec63a 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletBy &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( ?, ?, ?, ?, CURRENT_TIMESTAMP @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified bool + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(?2, birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(?3, birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(?3, birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -378,17 +378,17 @@ WHERE ` type UpdateWalletSyncStateParams struct { - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool - WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime + WalletID int64 } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, arg.WalletID, ) if err != nil { diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 4658c15e74..c50f16d8d5 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -52,11 +52,19 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, ) } + birthday := sql.NullTime{} + if !params.Birthday.IsZero() { + birthday = sql.NullTime{ + Time: params.Birthday, + Valid: true, + } + } + syncParams := sqlcpg.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt32{}, - BirthdayHeight: sql.NullInt32{}, - BirthdayVerified: false, + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + Birthday: birthday, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -83,6 +91,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -125,6 +134,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -153,6 +163,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -190,6 +201,13 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, syncParams.SyncedHeight = syncedHeight } + if params.Birthday != nil { + syncParams.Birthday = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + if params.BirthdayBlock != nil { birthdayHeight, err := uint32ToNullInt32( params.BirthdayBlock.Height) @@ -198,13 +216,6 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } syncParams.BirthdayHeight = birthdayHeight - - // Set birthday verified flag to true if the birthday - // block is set. - syncParams.BirthdayVerified = sql.NullBool{ - Bool: true, - Valid: true, - } } rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) @@ -284,6 +295,7 @@ type pgWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt32 + birthday sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -304,6 +316,10 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } + if row.birthday.Valid { + info.Birthday = row.birthday.Time + } + if row.syncedHeight.Valid { block, err := buildPgBlock( row.syncedHeight, @@ -327,8 +343,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { return nil, fmt.Errorf("birthday block: %w", err) } - info.BirthdayBlock = *block - info.Birthday = block.Timestamp + info.BirthdayBlock = block } return info, nil diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index ffed6a16f8..989328eb59 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -52,11 +52,19 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, ) } + birthday := sql.NullTime{} + if !params.Birthday.IsZero() { + birthday = sql.NullTime{ + Time: params.Birthday, + Valid: true, + } + } + syncParams := sqlcsqlite.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt64{}, - BirthdayHeight: sql.NullInt64{}, - BirthdayVerified: false, + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + Birthday: birthday, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -83,6 +91,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -125,6 +134,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -153,6 +163,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -187,16 +198,16 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } } - if params.BirthdayBlock != nil { - syncParams.BirthdayHeight = sql.NullInt64{ - Int64: int64(params.BirthdayBlock.Height), + if params.Birthday != nil { + syncParams.Birthday = sql.NullTime{ + Time: *params.Birthday, Valid: true, } + } - // Set birthday verified flag to true if the birthday - // block is set. - syncParams.BirthdayVerified = sql.NullBool{ - Bool: true, + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), Valid: true, } } @@ -278,6 +289,7 @@ type sqliteWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt64 + birthday sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -303,6 +315,10 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } + if row.birthday.Valid { + info.Birthday = row.birthday.Time + } + if row.syncedHeight.Valid { block, err := buildSqliteBlock( row.syncedHeight, @@ -326,8 +342,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { return nil, fmt.Errorf("birthday block: %w", err) } - info.BirthdayBlock = *block - info.Birthday = block.Timestamp + info.BirthdayBlock = block } return info, nil From c086a063b6cac2b2ff8c8248fd9bbe8e8ca4d520 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 16:32:07 +0000 Subject: [PATCH 027/695] unit-bench: make `make unit-bench benchmem=1` works Fixes https://github.com/btcsuite/btcwallet/issues/1063. --- make/testing_flags.mk | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/make/testing_flags.mk b/make/testing_flags.mk index 86b9d1e5f8..0c7951107e 100644 --- a/make/testing_flags.mk +++ b/make/testing_flags.mk @@ -50,6 +50,10 @@ ifneq ($(nocache),) TEST_FLAGS += -test.count=1 endif +ifneq ($(benchmem),) +TEST_FLAGS += -test.benchmem +endif + # Define the log tags that will be applied only when running unit tests. If none # are provided, we default to "debug stdlog" which will be standard debug log # output. @@ -72,7 +76,7 @@ UNIT_RACE := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -race $(UNI # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE $(UNITPKG) +UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE $(UNITPKG) endif ifeq ($(UNIT_TARGETED), no) @@ -81,7 +85,7 @@ UNIT_DEBUG := $(GOLIST) | $(XARGS) env $(GOTEST) -v -tags="$(DEV_TAGS) $(LOG_TAG # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE +UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE UNIT_RACE := $(UNIT) -race endif From 74401afe5013d36b657549e4ecfb234c8a3697b4 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 16:33:44 +0000 Subject: [PATCH 028/695] CI: verbose itest dbs running output for debugging --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ad81d16d43..686e99d4bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -188,4 +188,4 @@ jobs: go-version: '${{ env.GO_VERSION }}' - name: run ${{ matrix.db }} ${{ matrix.target }} - run: make ${{ matrix.target }} db=${{ matrix.db }} + run: make ${{ matrix.target }} db=${{ matrix.db }} verbose=1 From dfc05823bdaf115ed4e387cc497cdabb5bcfa5ab Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:53:49 +0000 Subject: [PATCH 029/695] Makefile+tools: utilize existing docker tool for protolint --- Makefile | 2 +- tools/Dockerfile | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c88fb154aa..fb1e00fc8e 100644 --- a/Makefile +++ b/Makefile @@ -156,7 +156,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - docker run --rm --volume "$$(pwd):/workspace" --workdir /workspace yoheimuta/protolint lint rpc/ + $(DOCKER_TOOLS) protolint lint rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install diff --git a/tools/Dockerfile b/tools/Dockerfile index 4b154a4e70..be715ca616 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -12,6 +12,7 @@ RUN go install -trimpath \ github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ github.com/rinchsan/gosimports/cmd/gosimports \ github.com/sqlc-dev/sqlc/cmd/sqlc \ + github.com/yoheimuta/protolint/cmd/protolint \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ From e38e26a6f44d840f66b59cfd0dd0d558db6fc319 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:55:27 +0000 Subject: [PATCH 030/695] Makefile+.protolint: move `.protolint.yml` file to unified config dir --- Makefile | 2 +- .protolint.yml => config/.protolint.yml | 0 tools/go.mod | 9 +++++++ tools/go.sum | 35 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) rename .protolint.yml => config/.protolint.yml (100%) diff --git a/Makefile b/Makefile index fb1e00fc8e..0963d50b93 100644 --- a/Makefile +++ b/Makefile @@ -156,7 +156,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - $(DOCKER_TOOLS) protolint lint rpc/ + $(DOCKER_TOOLS) protolint lint -config_dir_path=config rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install diff --git a/.protolint.yml b/config/.protolint.yml similarity index 100% rename from .protolint.yml rename to config/.protolint.yml diff --git a/tools/go.mod b/tools/go.mod index 6108ec8473..30c8651773 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -6,6 +6,7 @@ tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/rinchsan/gosimports/cmd/gosimports github.com/sqlc-dev/sqlc/cmd/sqlc + github.com/yoheimuta/protolint/cmd/protolint ) require ( @@ -55,6 +56,7 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chavacava/garif v0.1.0 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect @@ -70,6 +72,7 @@ require ( github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/gertd/go-pluralize v0.2.1 // indirect github.com/ghostiam/protogetter v0.3.15 // indirect github.com/go-critic/go-critic v0.13.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect @@ -104,10 +107,13 @@ require ( github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -153,6 +159,7 @@ require ( github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.20.0 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect @@ -218,6 +225,8 @@ require ( github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + github.com/yoheimuta/protolint v0.56.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.14.0 // indirect go-simpler.org/sloglint v0.11.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index 945fcce995..17c6b6fd96 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -115,6 +115,8 @@ github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= @@ -140,6 +142,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= +github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -173,6 +177,7 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -185,6 +190,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= @@ -337,8 +344,12 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -350,6 +361,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -365,6 +378,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -435,8 +450,12 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -470,6 +489,8 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8= github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= @@ -605,6 +626,8 @@ github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ai github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -612,6 +635,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= @@ -654,6 +681,10 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yoheimuta/protolint v0.56.4 h1:FWvXjVNRaKJWJFxsnilRZhfQ4tc3KS8VVGWecxnLXLo= +github.com/yoheimuta/protolint v0.56.4/go.mod h1:XrnOc0O5mckLR1GAOjqMPdb3R3ZEfLkMpLoq5RxxoG0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -843,6 +874,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -865,10 +897,13 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 580418941ca3b0fdb4c9dd1a46632d97ed3db40d Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:59:46 +0000 Subject: [PATCH 031/695] Makefile+.golangci: move `.golangci.yml` to unified config dir --- Makefile | 6 +++--- .golangci.yml => config/.golangci.yml | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename .golangci.yml => config/.golangci.yml (100%) diff --git a/Makefile b/Makefile index 0963d50b93..b6d575ee0e 100644 --- a/Makefile +++ b/Makefile @@ -126,17 +126,17 @@ rpc-format: #? lint-config-check: Verify golangci-lint configuration lint-config-check: docker-tools @$(call print, "Verifying golangci-lint configuration.") - $(DOCKER_TOOLS) golangci-lint config verify -v + $(DOCKER_TOOLS) golangci-lint config verify -v --config config/.golangci.yml #? lint: Lint source and check errors lint-check: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --config config/.golangci.yml $(LINT_WORKERS) #? lint: Lint source and fix lint: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --fix $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --fix --config config/.golangci.yml $(LINT_WORKERS) #? docker-tools: Build tools docker image docker-tools: diff --git a/.golangci.yml b/config/.golangci.yml similarity index 100% rename from .golangci.yml rename to config/.golangci.yml From f9b17ab5fe2cd63709c1c8489285c4de0f1e696e Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 05:02:51 +0000 Subject: [PATCH 032/695] Makefile+testing_flags: move make testing flags to unified config dir --- Makefile | 2 +- {make => config}/testing_flags.mk | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {make => config}/testing_flags.mk (100%) diff --git a/Makefile b/Makefile index b6d575ee0e..7e66b7e7ce 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ CP := cp MAKE := make XARGS := xargs -L 1 -include make/testing_flags.mk +include config/testing_flags.mk # Linting uses a lot of memory, so keep it under control by limiting the number # of workers if requested. diff --git a/make/testing_flags.mk b/config/testing_flags.mk similarity index 100% rename from make/testing_flags.mk rename to config/testing_flags.mk From 73a07375083c9d47a4a3ca13371cfb6c19543389 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:24:23 +0000 Subject: [PATCH 033/695] Makefile+sql: move `sqlc.yaml` to unified config directory --- Makefile | 2 +- {wallet/internal/db/sqlc => config}/sqlc.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) rename {wallet/internal/db/sqlc => config}/sqlc.yaml (80%) diff --git a/Makefile b/Makefile index 7e66b7e7ce..dbb9d8df53 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,7 @@ tidy-module-check: tidy-module #? sqlc: Generate sql models and queries in Go sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f wallet/internal/db/sqlc/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/wallet/internal/db/sqlc/sqlc.yaml b/config/sqlc.yaml similarity index 80% rename from wallet/internal/db/sqlc/sqlc.yaml rename to config/sqlc.yaml index 158d518a3b..5e9f3a162b 100644 --- a/wallet/internal/db/sqlc/sqlc.yaml +++ b/config/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "../migrations/postgres" - queries: "../queries/postgres" + schema: "../wallet/internal/db/migrations/postgres" + queries: "../wallet/internal/db/queries/postgres" gen: go: - out: "postgres" + out: "../wallet/internal/db/sqlc/postgres" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "../migrations/sqlite" - queries: "../queries/sqlite" + schema: "../wallet/internal/db/migrations/sqlite" + queries: "../wallet/internal/db/queries/sqlite" gen: go: - out: "sqlite" + out: "../wallet/internal/db/sqlc/sqlite" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. From 1b268327bdb566d7badbcdb4bcc260e3c2904abe Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:26:44 +0000 Subject: [PATCH 034/695] config+tools: prep `sqlfluff` for later makefile integration --- Makefile | 4 ++++ config/sqlfluff.cfg | 41 +++++++++++++++++++++++++++++++++++++++++ tools/Dockerfile | 8 ++++---- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 config/sqlfluff.cfg diff --git a/Makefile b/Makefile index dbb9d8df53..b65fcebac3 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,10 @@ DOCKER_TOOLS = docker run \ -v $(shell bash -c "mkdir -p /tmp/go-build-cache; echo /tmp/go-build-cache"):/root/.cache/go-build \ -v $$(pwd):/build btcwallet-tools +SQLFLUFF = docker run \ + --rm \ + -v $$(pwd):/sql sqlfluff/sqlfluff + GREEN := "\\033[0;32m" NC := "\\033[0m" define print diff --git a/config/sqlfluff.cfg b/config/sqlfluff.cfg new file mode 100644 index 0000000000..3ae05159ec --- /dev/null +++ b/config/sqlfluff.cfg @@ -0,0 +1,41 @@ +# SQLFluff configuration +# Source: https://docs.sqlfluff.com/en/stable/configuration/setting_configuration.html + +[sqlfluff] +# Supported dialects: postgres, sqlite +# We specify dialect via CLI (-d flag) to handle both postgres and sqlite +# sql_file_exts = .sql +# exclude_rules = None + +# Standard max line length +max_line_length = 120 + +# Use all available CPU cores for better performance +processes = 0 + +[sqlfluff:indentation] +# Use spaces for indentation +indent_unit = space +tab_space_size = 4 +indented_joins = False +indented_using_on = True + +[sqlfluff:rules:capitalisation.keywords] +# Keywords should be uppercase (e.g., SELECT, FROM, WHERE) +capitalisation_policy = upper + +[sqlfluff:rules:capitalisation.identifiers] +# Table and column names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.functions] +# Function names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.types] +# Data types should be uppercase (e.g., INTEGER, BYTEA, BLOB) +extended_capitalisation_policy = upper + +[sqlfluff:rules:convention.not_equal] +# Prefer != over <> for not equal +preferred_not_equal_style = c_style diff --git a/tools/Dockerfile b/tools/Dockerfile index be715ca616..1c2c7daf51 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -9,10 +9,10 @@ WORKDIR /build COPY tools/go.mod tools/go.sum ./ RUN go install -trimpath \ - github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ - github.com/rinchsan/gosimports/cmd/gosimports \ - github.com/sqlc-dev/sqlc/cmd/sqlc \ - github.com/yoheimuta/protolint/cmd/protolint \ + github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ + github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/sqlc-dev/sqlc/cmd/sqlc \ + github.com/yoheimuta/protolint/cmd/protolint \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ From 138dbc69787a06e656d4d64ad07456dbc0be0d01 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:39:12 +0000 Subject: [PATCH 035/695] Makefile+workflows: integrate sqlfluff metadev helpers with CI and Makefile --- .github/workflows/main.yml | 8 +++- Makefile | 76 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 686e99d4bd..f5cafec1db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,9 +63,15 @@ jobs: - name: Check RPC format run: make rpc-check - - name: Check SQL models + - name: Check generated SQL code is up-to-date run: make sqlc-check + - name: Check SQL formatting + run: make sql-format-check + + - name: Check SQL linting + run: make sql-lint-check + - name: compile code run: go install -v ./... diff --git a/Makefile b/Makefile index b65fcebac3..6463ea5cd2 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,17 @@ GOINSTALL := GO111MODULE=on go install -v GOFILES = $(shell find . -type f -name '*.go' -not -name "*.pb.go") + +# SQL directories. +SQL_MIGRATIONS_DIR := wallet/internal/db/migrations +SQL_QUERIES_DIR := wallet/internal/db/queries + +# SQL file paths. +SQL_POSTGRES_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/postgres +SQL_POSTGRES_QUERIES := $(SQL_QUERIES_DIR)/postgres +SQL_SQLITE_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/sqlite +SQL_SQLITE_QUERIES := $(SQL_QUERIES_DIR)/sqlite + RM := rm -f CP := cp MAKE := make @@ -26,7 +37,10 @@ DOCKER_TOOLS = docker run \ SQLFLUFF = docker run \ --rm \ - -v $$(pwd):/sql sqlfluff/sqlfluff + --user $$(id -u):$$(id -g) \ + -v $$(pwd):/sql \ + -w /sql \ + sqlfluff/sqlfluff GREEN := "\\033[0;32m" NC := "\\033[0m" @@ -181,16 +195,65 @@ tidy-module: tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi -#? sqlc: Generate sql models and queries in Go -sqlc: docker-tools +#? sql-parse: Ensures SQL files are syntactically valid +sql-parse: + @$(call print, "Validating SQL files (postgres migrations).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Validating SQL files (postgres queries).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Validating SQL files (sqlite migrations).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Validating SQL files (sqlite queries).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sqlc: Generate Go code from SQL queries and migrations +sqlc: sql-parse docker-tools @$(call print, "Generating sql models and queries in Go") $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml -#? sqlc-check: Make sure sql models and queries are up to date +#? sqlc-check: Verify generated Go SQL queries and migrations are up-to-date sqlc-check: sqlc @$(call print, "Verifying sql code generation.") if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi +#? sql-format: Format SQL migration and query files (like 'make fmt') +sql-format: + @$(call print, "Formatting SQL files (postgres migrations).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Formatting SQL files (postgres queries).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Formatting SQL files (sqlite migrations).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Formatting SQL files (sqlite queries).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') +sql-format-check: sql-format + @$(call print, "Checking SQL formatting.") + if test -n "$$(git status --porcelain '$(SQL_MIGRATIONS_DIR)/**/*.sql' '$(SQL_QUERIES_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi + +#? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') +sql-lint: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-lint-check: Lint SQL files and report errors (like 'make lint-check') +sql-lint-check: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + .PHONY: all \ default \ build \ @@ -206,8 +269,13 @@ sqlc-check: sqlc fmt-check \ tidy-module \ tidy-module-check \ + sql-parse \ sqlc \ sqlc-check \ + sql-format \ + sql-lint \ + sql-lint-check \ + sql-format-check \ rpc-format \ lint \ lint-config-check \ From c82d0e01734299822e16f788e502bc4dabf32ebb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 11 Dec 2025 17:03:43 +0000 Subject: [PATCH 036/695] wallet: lint and format SQL files as proposed by sqlfluff Co-authored-by: Mohamed Awnallah --- wallet/internal/db/itest/fixtures_test.go | 2 +- .../migrations/postgres/000001_blocks.up.sql | 2 +- .../migrations/postgres/000002_wallets.up.sql | 18 ++--- .../db/migrations/sqlite/000001_blocks.up.sql | 2 +- .../migrations/sqlite/000002_wallets.up.sql | 18 ++--- .../internal/db/queries/postgres/blocks.sql | 7 +- .../internal/db/queries/postgres/wallets.sql | 56 +++++++------- wallet/internal/db/queries/sqlite/blocks.sql | 7 +- wallet/internal/db/queries/sqlite/wallets.sql | 56 +++++++------- .../internal/db/sqlc/postgres/blocks.sql.go | 17 +++-- wallet/internal/db/sqlc/postgres/models.go | 8 +- wallet/internal/db/sqlc/postgres/querier.go | 2 +- .../internal/db/sqlc/postgres/wallets.sql.go | 76 +++++++++---------- wallet/internal/db/sqlc/sqlite/blocks.sql.go | 17 +++-- wallet/internal/db/sqlc/sqlite/models.go | 8 +- wallet/internal/db/sqlc/sqlite/querier.go | 2 +- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 76 +++++++++---------- wallet/internal/db/wallet_pg.go | 8 +- wallet/internal/db/wallet_sqlite.go | 8 +- 19 files changed, 201 insertions(+), 189 deletions(-) diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go index 2fa1fb6c83..78fc89aa57 100644 --- a/wallet/internal/db/itest/fixtures_test.go +++ b/wallet/internal/db/itest/fixtures_test.go @@ -59,7 +59,7 @@ func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { // TODO(gustavostingelin): use the block store to insert the block when // available. query := ` - INSERT INTO blocks (block_height, header_hash, timestamp) + INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) ` _, err := dbConn.ExecContext(t.Context(), query, diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql index f85ce44559..2880295c94 100644 --- a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql +++ b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql @@ -13,5 +13,5 @@ CREATE TABLE blocks ( header_hash BYTEA NOT NULL UNIQUE CHECK (length(header_hash) = 32), -- Unix timestamp - when the block was mined (seconds since epoch). - timestamp BIGINT NOT NULL CHECK (timestamp >= 0) + block_timestamp BIGINT NOT NULL CHECK (block_timestamp >= 0) ); diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index 6bf74e4a06..591dad932e 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -7,7 +7,7 @@ CREATE TABLE wallets ( id BIGSERIAL PRIMARY KEY, -- Human friendly name for the wallet. - name TEXT NOT NULL, + wallet_name TEXT NOT NULL, -- Defines if the wallet was imported, all its accounts would also be imported. is_imported BOOLEAN NOT NULL, @@ -30,7 +30,7 @@ CREATE TABLE wallets ( ); -- Unique index to prevent duplicate wallet names. -CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Wallet Secrets table to store rarely accessed, highly sensitive encrypted -- material with a strict one-to-one relationship with the wallets table. @@ -58,7 +58,7 @@ CREATE TABLE wallet_secrets ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if secrets still exist. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has at most one secrets record. @@ -90,20 +90,20 @@ CREATE TABLE wallet_sync_states ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if sync state still exists. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (synced_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT, + FOREIGN KEY (synced_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT + FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has exactly one sync state record. -- Also serves as the effective primary key for this table. CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet - ON wallet_sync_states (wallet_id); +ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql index 26a6397681..b4b523d111 100644 --- a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql +++ b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql @@ -13,5 +13,5 @@ CREATE TABLE blocks ( header_hash BLOB NOT NULL UNIQUE CHECK (length(header_hash) = 32), -- Unix timestamp - when the block was mined (seconds since epoch). - timestamp INTEGER NOT NULL CHECK (timestamp >= 0) + block_timestamp INTEGER NOT NULL CHECK (block_timestamp >= 0) ); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index eebcd592c3..766cba96d3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -7,7 +7,7 @@ CREATE TABLE wallets ( id INTEGER PRIMARY KEY, -- Human friendly name for the wallet. - name TEXT NOT NULL, + wallet_name TEXT NOT NULL, -- Defines if the wallet was imported, all its accounts would also be imported. is_imported BOOLEAN NOT NULL, @@ -30,7 +30,7 @@ CREATE TABLE wallets ( ); -- Unique index to prevent duplicate wallet names. -CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Wallet Secrets table to store rarely accessed, highly sensitive encrypted -- material with a strict one-to-one relationship with the wallets table. @@ -58,7 +58,7 @@ CREATE TABLE wallet_secrets ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if secrets still exist. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has at most one secrets record. @@ -90,20 +90,20 @@ CREATE TABLE wallet_sync_states ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if sync state still exists. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (synced_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT, + FOREIGN KEY (synced_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT + FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has exactly one sync state record. -- Also serves as the effective primary key for this table. CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet - ON wallet_sync_states (wallet_id); +ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql index dc9884ba32..daa8e13e89 100644 --- a/wallet/internal/db/queries/postgres/blocks.sql +++ b/wallet/internal/db/queries/postgres/blocks.sql @@ -1,10 +1,13 @@ -- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = $1; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index 248f3b98fe..bc01c5ecbc 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -1,6 +1,6 @@ -- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -20,30 +20,30 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - $1, $2, $3, $4, CURRENT_TIMESTAMP + $1, $2, $3, $4, current_timestamp ); -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + synced_height = coalesce(sqlc.narg('synced_height'), synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(sqlc.narg('birthday'), birthday), + birthday = coalesce(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = $1; -- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -52,19 +52,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = $1; + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = $1; -- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -73,19 +73,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id; -- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -94,13 +94,13 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = $1; -- name: InsertWalletSecrets :exec diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql index f37744df57..077e33c897 100644 --- a/wallet/internal/db/queries/sqlite/blocks.sql +++ b/wallet/internal/db/queries/sqlite/blocks.sql @@ -1,10 +1,13 @@ -- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = ?; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 0259e8801d..3999eb636f 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -1,6 +1,6 @@ -- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -20,30 +20,30 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - ?, ?, ?, ?, CURRENT_TIMESTAMP + ?, ?, ?, ?, current_timestamp ); -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + synced_height = coalesce(sqlc.narg('synced_height'), synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(sqlc.narg('birthday'), birthday), + birthday = coalesce(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = sqlc.arg('wallet_id'); -- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -52,19 +52,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = ?; + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = ?; -- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -73,19 +73,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id; -- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -94,13 +94,13 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = ?; -- name: InsertWalletSecrets :exec diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go index 8ca86ef812..c95f43abdb 100644 --- a/wallet/internal/db/sqlc/postgres/blocks.sql.go +++ b/wallet/internal/db/sqlc/postgres/blocks.sql.go @@ -20,7 +20,10 @@ func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int32) error { } const GetBlockByHeight = `-- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = $1 ` @@ -28,22 +31,22 @@ WHERE block_height = $1 func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) { row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) var i Block - err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) return i, err } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) ` type InsertBlockParams struct { - BlockHeight int32 - HeaderHash []byte - Timestamp int64 + BlockHeight int32 + HeaderHash []byte + BlockTimestamp int64 } func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { - _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 9f49ab37e5..2a8a0e55ee 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -10,14 +10,14 @@ import ( ) type Block struct { - BlockHeight int32 - HeaderHash []byte - Timestamp int64 + BlockHeight int32 + HeaderHash []byte + BlockTimestamp int64 } type Wallet struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 771a6ebdf2..9a3de084cd 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -13,7 +13,7 @@ type Querier interface { DeleteBlock(ctx context.Context, blockHeight int32) error GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) - GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index 408f4c982b..e60f36218c 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -12,7 +12,7 @@ import ( const CreateWallet = `-- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -26,7 +26,7 @@ RETURNING id ` type CreateWalletParams struct { - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -37,7 +37,7 @@ type CreateWalletParams struct { func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, - arg.Name, + arg.WalletName, arg.IsImported, arg.ManagerVersion, arg.IsWatchOnly, @@ -53,7 +53,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int const GetWalletByID = `-- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -62,19 +62,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = $1 ` type GetWalletByIDRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -93,7 +93,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow var i GetWalletByIDRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -112,7 +112,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow const GetWalletByName = `-- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -121,19 +121,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = $1 + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = $1 ` type GetWalletByNameRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -147,12 +147,12 @@ type GetWalletByNameRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { - row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) +func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, walletName) var i GetWalletByNameRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - $1, $2, $3, $4, CURRENT_TIMESTAMP + $1, $2, $3, $4, current_timestamp ) ` @@ -255,7 +255,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn const ListWallets = `-- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -264,19 +264,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id ` type ListWalletsRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -301,7 +301,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { var i ListWalletsRow if err := rows.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -363,16 +363,16 @@ const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE($2, synced_height), + synced_height = coalesce($2, synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE($3, birthday_height), + birthday_height = coalesce($3, birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE($4, birthday), + birthday = coalesce($4, birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = $1 ` diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go index 62f6afa0f5..f5eb1d3e00 100644 --- a/wallet/internal/db/sqlc/sqlite/blocks.sql.go +++ b/wallet/internal/db/sqlc/sqlite/blocks.sql.go @@ -20,7 +20,10 @@ func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int64) error { } const GetBlockByHeight = `-- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = ? ` @@ -28,22 +31,22 @@ WHERE block_height = ? func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) { row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) var i Block - err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) return i, err } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) ` type InsertBlockParams struct { - BlockHeight int64 - HeaderHash []byte - Timestamp int64 + BlockHeight int64 + HeaderHash []byte + BlockTimestamp int64 } func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { - _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 60113bdff6..3789221a04 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -10,14 +10,14 @@ import ( ) type Block struct { - BlockHeight int64 - HeaderHash []byte - Timestamp int64 + BlockHeight int64 + HeaderHash []byte + BlockTimestamp int64 } type Wallet struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 61cc0cbcfb..6f11ef506a 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -13,7 +13,7 @@ type Querier interface { DeleteBlock(ctx context.Context, blockHeight int64) error GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) - GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index ae878ec63a..8f38342fc8 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -12,7 +12,7 @@ import ( const CreateWallet = `-- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -26,7 +26,7 @@ RETURNING id ` type CreateWalletParams struct { - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -37,7 +37,7 @@ type CreateWalletParams struct { func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, - arg.Name, + arg.WalletName, arg.IsImported, arg.ManagerVersion, arg.IsWatchOnly, @@ -53,7 +53,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int const GetWalletByID = `-- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -62,19 +62,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = ? ` type GetWalletByIDRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -93,7 +93,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow var i GetWalletByIDRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -112,7 +112,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow const GetWalletByName = `-- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -121,19 +121,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = ? + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = ? ` type GetWalletByNameRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -147,12 +147,12 @@ type GetWalletByNameRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { - row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) +func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, walletName) var i GetWalletByNameRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - ?, ?, ?, ?, CURRENT_TIMESTAMP + ?, ?, ?, ?, current_timestamp ) ` @@ -255,7 +255,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn const ListWallets = `-- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -264,19 +264,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id ` type ListWalletsRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -301,7 +301,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { var i ListWalletsRow if err := rows.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -363,16 +363,16 @@ const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(?1, synced_height), + synced_height = coalesce(?1, synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(?2, birthday_height), + birthday_height = coalesce(?2, birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(?3, birthday), + birthday = coalesce(?3, birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = ?4 ` diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index c50f16d8d5..1ac42a3ddb 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -22,7 +22,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ - Name: params.Name, + WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: params.ManagerVersion, IsWatchOnly: params.IsWatchOnly, @@ -83,7 +83,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, info, err = buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -126,7 +126,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, return buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -155,7 +155,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, for i, row := range rows { info, err := buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 989328eb59..8c988d4981 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -22,7 +22,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ - Name: params.Name, + WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: int64(params.ManagerVersion), IsWatchOnly: params.IsWatchOnly, @@ -83,7 +83,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -126,7 +126,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, return buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -155,7 +155,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, for i, row := range rows { info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, From e0355b2474a2bfaff53d7ae70a66886fc8b05d16 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 16:54:02 -0300 Subject: [PATCH 037/695] wallet: fix code style issues --- wallet/internal/db/wallet_pg.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 1ac42a3ddb..8eef576717 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -193,7 +193,8 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, if params.SyncedTo != nil { syncedHeight, err := uint32ToNullInt32( - params.SyncedTo.Height) + params.SyncedTo.Height, + ) if err != nil { return err } @@ -210,7 +211,8 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, if params.BirthdayBlock != nil { birthdayHeight, err := uint32ToNullInt32( - params.BirthdayBlock.Height) + params.BirthdayBlock.Height, + ) if err != nil { return err } From c4ffb4bf2974307eb77100fc67fa43ea8b5e0174 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 17:12:14 -0300 Subject: [PATCH 038/695] wallet: rename wallet sync table birthday to birthday_timestamp --- .../migrations/postgres/000002_wallets.up.sql | 2 +- .../migrations/sqlite/000002_wallets.up.sql | 2 +- .../internal/db/queries/postgres/wallets.sql | 12 ++--- wallet/internal/db/queries/sqlite/wallets.sql | 12 ++--- wallet/internal/db/sqlc/postgres/models.go | 10 ++--- .../internal/db/sqlc/postgres/wallets.sql.go | 44 +++++++++---------- wallet/internal/db/sqlc/sqlite/models.go | 10 ++--- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 44 +++++++++---------- wallet/internal/db/wallet_pg.go | 26 +++++------ wallet/internal/db/wallet_sqlite.go | 26 +++++------ 10 files changed, 94 insertions(+), 94 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index 591dad932e..c6af352892 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -83,7 +83,7 @@ CREATE TABLE wallet_sync_states ( birthday_height INTEGER, -- User-provided birthday timestamp for wallet rescan. NULL if not set. - birthday TIMESTAMP, + birthday_timestamp TIMESTAMP, -- Last updated timestamp stored in UTC without timezone info. updated_at TIMESTAMP NOT NULL, diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 766cba96d3..253849cfb7 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -83,7 +83,7 @@ CREATE TABLE wallet_sync_states ( birthday_height INTEGER, -- User-provided birthday timestamp for wallet rescan. NULL if not set. - birthday DATETIME, + birthday_timestamp DATETIME, -- Last updated timestamp stored in UTC without timezone info. updated_at DATETIME NOT NULL, diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index bc01c5ecbc..b1033fc7b2 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( $1, $2, $3, $4, current_timestamp @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(sqlc.narg('birthday'), birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 3999eb636f..3e180b39dd 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( ?, ?, ?, ?, current_timestamp @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(sqlc.narg('birthday'), birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 2a8a0e55ee..3282b71b51 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index e60f36218c..cddc263bf3 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWa &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( $1, $2, $3, $4, current_timestamp @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce($3, birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce($4, birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce($4, birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -378,10 +378,10 @@ WHERE ` type UpdateWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { @@ -389,7 +389,7 @@ func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) if err != nil { return 0, err diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 3789221a04..8a93984406 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 8f38342fc8..56685d4f7b 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWa &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( ?, ?, ?, ?, current_timestamp @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(?2, birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(?3, birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(?3, birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -378,17 +378,17 @@ WHERE ` type UpdateWalletSyncStateParams struct { - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime - WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime + WalletID int64 } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, arg.WalletID, ) if err != nil { diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 8eef576717..e6f1b54e95 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -52,19 +52,19 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, ) } - birthday := sql.NullTime{} + birthdayTimestamp := sql.NullTime{} if !params.Birthday.IsZero() { - birthday = sql.NullTime{ + birthdayTimestamp = sql.NullTime{ Time: params.Birthday, Valid: true, } } syncParams := sqlcpg.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt32{}, - BirthdayHeight: sql.NullInt32{}, - Birthday: birthday, + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + BirthdayTimestamp: birthdayTimestamp, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -91,7 +91,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -134,7 +134,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -163,7 +163,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -203,7 +203,7 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } if params.Birthday != nil { - syncParams.Birthday = sql.NullTime{ + syncParams.BirthdayTimestamp = sql.NullTime{ Time: *params.Birthday, Valid: true, } @@ -297,7 +297,7 @@ type pgWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt32 - birthday sql.NullTime + birthdayTimestamp sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -318,8 +318,8 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } - if row.birthday.Valid { - info.Birthday = row.birthday.Time + if row.birthdayTimestamp.Valid { + info.Birthday = row.birthdayTimestamp.Time } if row.syncedHeight.Valid { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 8c988d4981..732242cb08 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -52,19 +52,19 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, ) } - birthday := sql.NullTime{} + birthdayTimestamp := sql.NullTime{} if !params.Birthday.IsZero() { - birthday = sql.NullTime{ + birthdayTimestamp = sql.NullTime{ Time: params.Birthday, Valid: true, } } syncParams := sqlcsqlite.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt64{}, - BirthdayHeight: sql.NullInt64{}, - Birthday: birthday, + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + BirthdayTimestamp: birthdayTimestamp, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -91,7 +91,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -134,7 +134,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -163,7 +163,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -199,7 +199,7 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } if params.Birthday != nil { - syncParams.Birthday = sql.NullTime{ + syncParams.BirthdayTimestamp = sql.NullTime{ Time: *params.Birthday, Valid: true, } @@ -289,7 +289,7 @@ type sqliteWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt64 - birthday sql.NullTime + birthdayTimestamp sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -315,8 +315,8 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } - if row.birthday.Valid { - info.Birthday = row.birthday.Time + if row.birthdayTimestamp.Valid { + info.Birthday = row.birthdayTimestamp.Time } if row.syncedHeight.Valid { From a828b15cf7a1b53c5ab0b53809ab77ac4e1e3c68 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 10:26:03 -0300 Subject: [PATCH 039/695] wallet: fix pg itest "too many clients" --- wallet/internal/db/itest/pg_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 41065c45b7..1d6f6f2710 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -142,6 +142,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { // Connect to the default database to create our test database. adminDB, err := sql.Open("pgx", connStr) require.NoError(t, err, "failed to open admin connection") + require.NotNil(t, adminDB, "admin connection is nil") + + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + t.Cleanup(func() { + _ = adminDB.Close() + }) // Create a database name based on the test name. dbName := sanitizedPgDBName(t) @@ -158,6 +165,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { // connection constructor when available. dbConn, err := sql.Open("pgx", testConnStr) require.NoError(t, err, "failed to open test database connection") + require.NotNil(t, dbConn, "test database connection is nil") + + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + t.Cleanup(func() { + _ = dbConn.Close() + }) err = db.ApplyPostgresMigrations(dbConn) require.NoError(t, err, "failed to apply migrations") From b6b6a7e895c785e020d61d28bb5fe882e8947cc5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:55:42 -0300 Subject: [PATCH 040/695] wallet: add address types migrations and queries --- .../postgres/000003_address_types.down.sql | 3 + .../postgres/000003_address_types.up.sql | 32 ++++++++++ .../sqlite/000003_address_types.down.sql | 3 + .../sqlite/000003_address_types.up.sql | 32 ++++++++++ .../db/queries/postgres/address_types.sql | 15 +++++ .../db/queries/sqlite/address_types.sql | 15 +++++ .../db/sqlc/postgres/address_types.sql.go | 58 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 20 +++++++ wallet/internal/db/sqlc/postgres/models.go | 5 ++ wallet/internal/db/sqlc/postgres/querier.go | 4 ++ .../db/sqlc/sqlite/address_types.sql.go | 58 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 20 +++++++ wallet/internal/db/sqlc/sqlite/models.go | 5 ++ wallet/internal/db/sqlc/sqlite/querier.go | 4 ++ 14 files changed, 274 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000003_address_types.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000003_address_types.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000003_address_types.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000003_address_types.up.sql create mode 100644 wallet/internal/db/queries/postgres/address_types.sql create mode 100644 wallet/internal/db/queries/sqlite/address_types.sql create mode 100644 wallet/internal/db/sqlc/postgres/address_types.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/address_types.sql.go diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.down.sql b/wallet/internal/db/migrations/postgres/000003_address_types.down.sql new file mode 100644 index 0000000000..1d524ac5c2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000003_address_types.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS address_types; diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql new file mode 100644 index 0000000000..0f474e79bb --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql @@ -0,0 +1,32 @@ +-- Address type lookup table - provides standardized descriptions for Bitcoin +-- address types. This is a reference table that maps the AddressType enum +-- values used in Go code to their human-readable Bitcoin protocol names. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE address_types ( + -- Primary key matching the Go AddressType enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id SMALLINT PRIMARY KEY, + + -- Human-readable Bitcoin address type description. + -- These match standard Bitcoin protocol terminology. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_address_types_description +ON address_types (description); + +-- Seed reference data matching the Go AddressType enum constants. +-- These values are static and represent the Bitcoin address types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO address_types (id, description) VALUES +(0, 'P2PKH'), -- Pay-to-PubKey-Hash +(1, 'P2SH'), -- Pay-to-Script-Hash +(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(5, 'P2TR'); -- Pay-to-Taproot diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql new file mode 100644 index 0000000000..1d524ac5c2 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS address_types; diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql new file mode 100644 index 0000000000..8722966aa6 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql @@ -0,0 +1,32 @@ +-- Address type lookup table - provides standardized descriptions for Bitcoin +-- address types. This is a reference table that maps the AddressType enum +-- values used in Go code to their human-readable Bitcoin protocol names. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE address_types ( + -- Primary key matching the Go AddressType enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id INTEGER PRIMARY KEY, + + -- Human-readable Bitcoin address type description. + -- These match standard Bitcoin protocol terminology. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_address_types_description +ON address_types (description); + +-- Seed reference data matching the Go AddressType enum constants. +-- These values are static and represent the Bitcoin address types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO address_types (id, description) VALUES +(0, 'P2PKH'), -- Pay-to-PubKey-Hash +(1, 'P2SH'), -- Pay-to-Script-Hash +(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(5, 'P2TR'); -- Pay-to-Taproot diff --git a/wallet/internal/db/queries/postgres/address_types.sql b/wallet/internal/db/queries/postgres/address_types.sql new file mode 100644 index 0000000000..ceb86a4c8e --- /dev/null +++ b/wallet/internal/db/queries/postgres/address_types.sql @@ -0,0 +1,15 @@ +-- name: ListAddressTypes :many +-- Returns all address types ordered by ID. +SELECT + id, + description +FROM address_types +ORDER BY id; + +-- name: GetAddressTypeByID :one +-- Returns a single address type by its ID. +SELECT + id, + description +FROM address_types +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/address_types.sql b/wallet/internal/db/queries/sqlite/address_types.sql new file mode 100644 index 0000000000..6f4bcc0b04 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/address_types.sql @@ -0,0 +1,15 @@ +-- name: ListAddressTypes :many +-- Returns all address types ordered by ID. +SELECT + id, + description +FROM address_types +ORDER BY id; + +-- name: GetAddressTypeByID :one +-- Returns a single address type by its ID. +SELECT + id, + description +FROM address_types +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/address_types.sql.go b/wallet/internal/db/sqlc/postgres/address_types.sql.go new file mode 100644 index 0000000000..86e7a4a531 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/address_types.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: address_types.sql + +package sqlcpg + +import ( + "context" +) + +const GetAddressTypeByID = `-- name: GetAddressTypeByID :one +SELECT + id, + description +FROM address_types +WHERE id = $1 +` + +// Returns a single address type by its ID. +func (q *Queries) GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) { + row := q.queryRow(ctx, q.getAddressTypeByIDStmt, GetAddressTypeByID, id) + var i AddressType + err := row.Scan(&i.ID, &i.Description) + return i, err +} + +const ListAddressTypes = `-- name: ListAddressTypes :many +SELECT + id, + description +FROM address_types +ORDER BY id +` + +// Returns all address types ordered by ID. +func (q *Queries) ListAddressTypes(ctx context.Context) ([]AddressType, error) { + rows, err := q.query(ctx, q.listAddressTypesStmt, ListAddressTypes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AddressType + for rows.Next() { + var i AddressType + if err := rows.Scan(&i.ID, &i.Description); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 57320c40fb..77bc5e5392 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -51,6 +54,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -75,6 +81,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.getAddressTypeByIDStmt != nil { + if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -110,6 +121,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAddressTypesStmt != nil { + if cerr := q.listAddressTypesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -166,6 +182,7 @@ type Queries struct { tx *sql.Tx createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt @@ -173,6 +190,7 @@ type Queries struct { insertBlockStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt listWalletsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -184,6 +202,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, @@ -191,6 +210,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { insertBlockStmt: q.insertBlockStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, listWalletsStmt: q.listWalletsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3282b71b51..920f8ce7aa 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -9,6 +9,11 @@ import ( "time" ) +type AddressType struct { + ID int16 + Description string +} + type Block struct { BlockHeight int32 HeaderHash []byte diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 9a3de084cd..30988e20a5 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -11,6 +11,8 @@ import ( type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Returns a single address type by its ID. + GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) @@ -18,6 +20,8 @@ type Querier interface { InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Returns all address types ordered by ID. + ListAddressTypes(ctx context.Context) ([]AddressType, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/address_types.sql.go b/wallet/internal/db/sqlc/sqlite/address_types.sql.go new file mode 100644 index 0000000000..5aa5cfdd5b --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/address_types.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: address_types.sql + +package sqlcsqlite + +import ( + "context" +) + +const GetAddressTypeByID = `-- name: GetAddressTypeByID :one +SELECT + id, + description +FROM address_types +WHERE id = ? +` + +// Returns a single address type by its ID. +func (q *Queries) GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) { + row := q.queryRow(ctx, q.getAddressTypeByIDStmt, GetAddressTypeByID, id) + var i AddressType + err := row.Scan(&i.ID, &i.Description) + return i, err +} + +const ListAddressTypes = `-- name: ListAddressTypes :many +SELECT + id, + description +FROM address_types +ORDER BY id +` + +// Returns all address types ordered by ID. +func (q *Queries) ListAddressTypes(ctx context.Context) ([]AddressType, error) { + rows, err := q.query(ctx, q.listAddressTypesStmt, ListAddressTypes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AddressType + for rows.Next() { + var i AddressType + if err := rows.Scan(&i.ID, &i.Description); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index c7080a445a..685bbdd06a 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -51,6 +54,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -75,6 +81,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.getAddressTypeByIDStmt != nil { + if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -110,6 +121,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAddressTypesStmt != nil { + if cerr := q.listAddressTypesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -166,6 +182,7 @@ type Queries struct { tx *sql.Tx createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt @@ -173,6 +190,7 @@ type Queries struct { insertBlockStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt listWalletsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -184,6 +202,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, @@ -191,6 +210,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { insertBlockStmt: q.insertBlockStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, listWalletsStmt: q.listWalletsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 8a93984406..448a62ca65 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -9,6 +9,11 @@ import ( "time" ) +type AddressType struct { + ID int64 + Description string +} + type Block struct { BlockHeight int64 HeaderHash []byte diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 6f11ef506a..edcbb3cdf7 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -11,6 +11,8 @@ import ( type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Returns a single address type by its ID. + GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) @@ -18,6 +20,8 @@ type Querier interface { InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Returns all address types ordered by ID. + ListAddressTypes(ctx context.Context) ([]AddressType, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From b8210ae95bfaf86344dc71529bfc1f7f28616295 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:56:28 -0300 Subject: [PATCH 041/695] wallet: add safecasting for uint8 --- wallet/internal/db/safecasting.go | 22 ++++++ wallet/internal/db/safecasting_test.go | 100 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 1ddc2b0827..9ffbea2f2b 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -43,6 +43,28 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } +// int64ToUint8 safely casts an int64 to an uint8, returning an error +// if the value is out of range. +func int64ToUint8(v int64) (uint8, error) { + if v < 0 || v > math.MaxUint8 { + return 0, fmt.Errorf("could not cast %d to uint8: %w", v, + ErrCastingOverflow) + } + + return uint8(v), nil +} + +// int16ToUint8 safely casts an int16 to an uint8, returning an error +// if the value is out of range. +func int16ToUint8(v int16) (uint8, error) { + if v < 0 || v > math.MaxUint8 { + return 0, fmt.Errorf("could not cast %d to uint8: %w", v, + ErrCastingOverflow) + } + + return uint8(v), nil +} + // uint32ToInt32 safely casts an uint32 to an int32, returning an error // if the value is out of range. func uint32ToInt32(v uint32) (int32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index d87fb89466..ec4b4d748a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -100,6 +100,106 @@ func TestInt64ToInt32(t *testing.T) { } } +// TestInt64ToUint8 checks that an int64 value is converted to uint8 only +// when it is non-negative and fits within the uint8 range. It should fail +// loudly for any value outside those bounds. +func TestInt64ToUint8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want uint8 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: 0, + }, + { + name: "max uint8", + val: int64(math.MaxUint8), + want: math.MaxUint8, + }, + { + name: "negative", + val: -1, + wantErr: true, + }, + { + name: "too large", + val: int64(math.MaxUint8) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToUint8(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestInt16ToUint8 checks that an int16 value is converted to uint8 only +// when it is non-negative and fits within the uint8 range. It should fail +// loudly for any value outside those bounds. +func TestInt16ToUint8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int16 + want uint8 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: 0, + }, + { + name: "max uint8", + val: int16(math.MaxUint8), + want: math.MaxUint8, + }, + { + name: "negative", + val: -1, + wantErr: true, + }, + { + name: "too large", + val: int16(math.MaxUint8) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int16ToUint8(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From dd56e5dad9fc084ea7515447e7924cdd4a0f1e66 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:57:15 -0300 Subject: [PATCH 042/695] wallet: add address types store implementation --- wallet/internal/db/address_types_common.go | 63 ++++++++++ wallet/internal/db/address_types_pg.go | 39 ++++++ wallet/internal/db/address_types_sqlite.go | 41 ++++++ wallet/internal/db/data_types.go | 19 +++ wallet/internal/db/interface.go | 12 ++ .../db/itest/address_types_store_test.go | 117 ++++++++++++++++++ 6 files changed, 291 insertions(+) create mode 100644 wallet/internal/db/address_types_common.go create mode 100644 wallet/internal/db/address_types_pg.go create mode 100644 wallet/internal/db/address_types_sqlite.go create mode 100644 wallet/internal/db/itest/address_types_store_test.go diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go new file mode 100644 index 0000000000..fa69e047b0 --- /dev/null +++ b/wallet/internal/db/address_types_common.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +func addressTypeInfosFromRows[T any](rows []T, + toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + types := make([]AddressTypeInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + types[i] = info + } + + return types, nil +} + +func listAddressTypes[T any](ctx context.Context, + list func(context.Context) ([]T, error), + toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + rows, err := list(ctx) + if err != nil { + return nil, fmt.Errorf("list address types: %w", err) + } + + return addressTypeInfosFromRows(rows, toInfo) +} + +func getAddressTypeByID[T any, ID any](ctx context.Context, + get func(context.Context, ID) (T, error), queryID ID, + id AddressType, toInfo func(T) (AddressTypeInfo, error)) ( + AddressTypeInfo, error) { + + row, err := get(ctx, queryID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AddressTypeInfo{}, fmt.Errorf( + "address type %d: %w", id, + ErrAddressTypeNotFound, + ) + } + + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", + err) + } + + info, err := toInfo(row) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", + err) + } + + return info, nil +} diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go new file mode 100644 index 0000000000..6f906ccc5d --- /dev/null +++ b/wallet/internal/db/address_types_pg.go @@ -0,0 +1,39 @@ +package db + +import ( + "context" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { + id, err := int16ToUint8(row.ID) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", + row.ID, err) + } + + return AddressTypeInfo{ + Type: AddressType(id), + Description: row.Description, + }, nil +} + +// ListAddressTypes returns all supported address types along with their +// readable descriptions, wrapped in AddressTypeInfo values. +func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( + []AddressTypeInfo, error) { + + return listAddressTypes(ctx, w.queries.ListAddressTypes, + pgAddressTypeToInfo) +} + +// GetAddressType returns the AddressTypeInfo associated with the given address +// type identifier. An error is returned if the type is unknown. +func (w *PostgresWalletDB) GetAddressType(ctx context.Context, + id AddressType) (AddressTypeInfo, error) { + + return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, + int16(id), id, pgAddressTypeToInfo) +} diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go new file mode 100644 index 0000000000..b1b2d998bd --- /dev/null +++ b/wallet/internal/db/address_types_sqlite.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, + error) { + + id, err := int64ToUint8(row.ID) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", + row.ID, err) + } + + return AddressTypeInfo{ + Type: AddressType(id), + Description: row.Description, + }, nil +} + +// ListAddressTypes returns all supported address types along with their +// readable descriptions, wrapped in AddressTypeInfo values. +func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( + []AddressTypeInfo, error) { + + return listAddressTypes(ctx, w.queries.ListAddressTypes, + sqliteAddressTypeToInfo) +} + +// GetAddressType returns the AddressTypeInfo associated with the given address +// type identifier. An error is returned if the type is unknown. +func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, + id AddressType) (AddressTypeInfo, error) { + + return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, + int64(id), id, sqliteAddressTypeToInfo) +} diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 4abd41d381..4eb410f400 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -31,6 +31,9 @@ type KeyScope struct { // AddressType specifies the type of a managed address. This is used to // identify the script type of an address, such as P2PKH, P2SH, P2WKH, etc. +// +// The enum values MUST match the IDs in the address_types database table. +// See migration 000003_address_types for the canonical descriptions. type AddressType uint8 const ( @@ -44,6 +47,10 @@ const ( // address. WitnessPubKey + // WitnessScript represents a pay-to-witness-script-hash (P2WSH) + // address. + WitnessScript + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH // address. NestedWitnessPubKey @@ -52,6 +59,18 @@ const ( TaprootPubKey ) +// AddressTypeInfo groups an address type identifier with its readable +// description. +type AddressTypeInfo struct { + // Type is the AddressType value used as the unique identifier on both + // the application side and the database side. + Type AddressType + + // Description is a readable description of the address type. + // It is intended for argument parsing, logging, and user-facing output. + Description string +} + const ( // BIP0044Purpose is the purpose field for BIP0044 derivation. BIP0044Purpose = 44 diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 1a88cc6130..d5709a5a6f 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -14,6 +14,9 @@ var ( // database. ErrWalletNotFound = errors.New("wallet not found") + // ErrAddressTypeNotFound is returned when an address type is not found. + ErrAddressTypeNotFound = errors.New("address type not found") + // ErrSecretNotFound is returned when a secret is not found or is empty // in the database. ErrSecretNotFound = errors.New("secret not found") @@ -135,6 +138,15 @@ type AddressStore interface { // imports. GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( *btcec.PrivateKey, error) + + // ListAddressTypes returns all supported address types along with their + // readable descriptions, wrapped in AddressTypeInfo values. + ListAddressTypes(ctx context.Context) ([]AddressTypeInfo, error) + + // GetAddressType returns the AddressTypeInfo associated with the given + // address type identifier. An error is returned if the type is unknown. + GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, + error) } // TxStore defines the database actions for managing transaction records. diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go new file mode 100644 index 0000000000..27653f499e --- /dev/null +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -0,0 +1,117 @@ +//go:build itest + +package itest + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +func TestListAddressTypes(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + types, err := store.ListAddressTypes(t.Context()) + require.NoError(t, err) + + want := []db.AddressTypeInfo{ + {Type: db.PubKeyHash, Description: "P2PKH"}, + {Type: db.ScriptHash, Description: "P2SH"}, + {Type: db.WitnessPubKey, Description: "P2WPKH"}, + {Type: db.WitnessScript, Description: "P2WSH"}, + {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, + {Type: db.TaprootPubKey, Description: "P2TR"}, + } + + require.Equal(t, want, types) +} + +func TestGetAddressType(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + tests := []struct { + id db.AddressType + wantResult *db.AddressTypeInfo + wantErr error + }{ + { + id: 0, + wantResult: &db.AddressTypeInfo{ + Type: db.PubKeyHash, + Description: "P2PKH", + }, + }, + { + id: 1, + wantResult: &db.AddressTypeInfo{ + Type: db.ScriptHash, + Description: "P2SH", + }, + }, + { + id: 2, + wantResult: &db.AddressTypeInfo{ + Type: db.WitnessPubKey, + Description: "P2WPKH", + }, + }, + { + id: 3, + wantResult: &db.AddressTypeInfo{ + Type: db.WitnessScript, + Description: "P2WSH", + }, + }, + { + id: 4, + wantResult: &db.AddressTypeInfo{ + Type: db.NestedWitnessPubKey, + Description: "P2SH-P2WPKH", + }, + }, + { + id: 5, + wantResult: &db.AddressTypeInfo{ + Type: db.TaprootPubKey, + Description: "P2TR", + }, + }, + { + // Means last valid plus one, so it should be invalid + id: 6, + wantErr: db.ErrAddressTypeNotFound, + }, + { + // some other invalid id + id: 100, + wantErr: db.ErrAddressTypeNotFound, + }, + } + + for _, tc := range tests { + name := fmt.Sprintf("id_%d_expect_", tc.id) + if tc.wantResult != nil { + name += tc.wantResult.Description + } else { + name += tc.wantErr.Error() + } + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := store.GetAddressType(t.Context(), tc.id) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, *tc.wantResult, got) + }) + } +} From ab0bcf559417efbe631eab9f015ee9ea2a0addcb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 17 Dec 2025 14:50:22 -0300 Subject: [PATCH 043/695] wallet: add P2A and P2PK address types --- wallet/internal/db/data_types.go | 16 +++++--- .../db/itest/address_types_store_test.go | 38 +++++++++++++------ .../postgres/000003_address_types.up.sql | 14 ++++--- .../sqlite/000003_address_types.up.sql | 14 ++++--- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 4eb410f400..c00fed1f12 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -37,12 +37,19 @@ type KeyScope struct { type AddressType uint8 const ( + // RawPubKey represents a pay-to-pubkey (P2PK) address. + RawPubKey AddressType = iota + // PubKeyHash represents a pay-to-pubkey-hash (P2PKH) address. - PubKeyHash AddressType = iota + PubKeyHash // ScriptHash represents a pay-to-script-hash (P2SH) address. ScriptHash + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH + // address. + NestedWitnessPubKey + // WitnessPubKey represents a pay-to-witness-pubkey-hash (P2WKH) // address. WitnessPubKey @@ -51,12 +58,11 @@ const ( // address. WitnessScript - // NestedWitnessPubKey represents a P2WKH output nested within a P2SH - // address. - NestedWitnessPubKey - // TaprootPubKey represents a pay-to-taproot (P2TR) address. TaprootPubKey + + // Anchor represents a pay-to-anchor (P2A) address. + Anchor ) // AddressTypeInfo groups an address type identifier with its readable diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index 27653f499e..16586a8224 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -18,12 +18,14 @@ func TestListAddressTypes(t *testing.T) { require.NoError(t, err) want := []db.AddressTypeInfo{ + {Type: db.RawPubKey, Description: "P2PK"}, {Type: db.PubKeyHash, Description: "P2PKH"}, {Type: db.ScriptHash, Description: "P2SH"}, + {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, {Type: db.WitnessPubKey, Description: "P2WPKH"}, {Type: db.WitnessScript, Description: "P2WSH"}, - {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, {Type: db.TaprootPubKey, Description: "P2TR"}, + {Type: db.Anchor, Description: "P2A"}, } require.Equal(t, want, types) @@ -40,49 +42,63 @@ func TestGetAddressType(t *testing.T) { }{ { id: 0, + wantResult: &db.AddressTypeInfo{ + Type: db.RawPubKey, + Description: "P2PK", + }, + }, + { + id: 1, wantResult: &db.AddressTypeInfo{ Type: db.PubKeyHash, Description: "P2PKH", }, }, { - id: 1, + id: 2, wantResult: &db.AddressTypeInfo{ Type: db.ScriptHash, Description: "P2SH", }, }, { - id: 2, + id: 3, + wantResult: &db.AddressTypeInfo{ + Type: db.NestedWitnessPubKey, + Description: "P2SH-P2WPKH", + }, + }, + { + id: 4, wantResult: &db.AddressTypeInfo{ Type: db.WitnessPubKey, Description: "P2WPKH", }, }, { - id: 3, + id: 5, wantResult: &db.AddressTypeInfo{ Type: db.WitnessScript, Description: "P2WSH", }, }, { - id: 4, + id: 6, wantResult: &db.AddressTypeInfo{ - Type: db.NestedWitnessPubKey, - Description: "P2SH-P2WPKH", + Type: db.TaprootPubKey, + Description: "P2TR", }, }, { - id: 5, + id: 7, wantResult: &db.AddressTypeInfo{ - Type: db.TaprootPubKey, - Description: "P2TR", + Type: db.Anchor, + Description: "P2A", }, }, { // Means last valid plus one, so it should be invalid - id: 6, + id: 8, wantErr: db.ErrAddressTypeNotFound, }, { diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql index 0f474e79bb..ac810bbfb7 100644 --- a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql +++ b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql @@ -24,9 +24,11 @@ ON address_types (description); -- These values are static and represent the Bitcoin address types. -- IDs MUST match the iota values in wallet/internal/db/data_types.go. INSERT INTO address_types (id, description) VALUES -(0, 'P2PKH'), -- Pay-to-PubKey-Hash -(1, 'P2SH'), -- Pay-to-Script-Hash -(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash -(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash -(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey -(5, 'P2TR'); -- Pay-to-Taproot +(0, 'P2PK'), -- Pay-to-PubKey +(1, 'P2PKH'), -- Pay-to-PubKey-Hash +(2, 'P2SH'), -- Pay-to-Script-Hash +(3, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(4, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(5, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(6, 'P2TR'), -- Pay-to-Taproot +(7, 'P2A'); -- Pay-to-Anchor diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql index 8722966aa6..0ced45fd58 100644 --- a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql @@ -24,9 +24,11 @@ ON address_types (description); -- These values are static and represent the Bitcoin address types. -- IDs MUST match the iota values in wallet/internal/db/data_types.go. INSERT INTO address_types (id, description) VALUES -(0, 'P2PKH'), -- Pay-to-PubKey-Hash -(1, 'P2SH'), -- Pay-to-Script-Hash -(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash -(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash -(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey -(5, 'P2TR'); -- Pay-to-Taproot +(0, 'P2PK'), -- Pay-to-PubKey +(1, 'P2PKH'), -- Pay-to-PubKey-Hash +(2, 'P2SH'), -- Pay-to-Script-Hash +(3, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(4, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(5, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(6, 'P2TR'), -- Pay-to-Taproot +(7, 'P2A'); -- Pay-to-Anchor From 9a313ea6ffaa0f9c04385a2a9c5436acef83be5f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 23:23:34 -0300 Subject: [PATCH 044/695] wallet: add key scope SQL migrations --- .../postgres/000004_key_scopes.down.sql | 4 ++ .../postgres/000004_key_scopes.up.sql | 64 +++++++++++++++++++ .../sqlite/000004_key_scopes.down.sql | 4 ++ .../sqlite/000004_key_scopes.up.sql | 64 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 15 +++++ wallet/internal/db/sqlc/sqlite/models.go | 15 +++++ 6 files changed, 166 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql new file mode 100644 index 0000000000..5f22164220 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS key_scope_secrets; +DROP TABLE IF EXISTS key_scopes; diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql new file mode 100644 index 0000000000..e7165cdcc0 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -0,0 +1,64 @@ +-- Key Scopes table to store different key scopes (BIP standards) for each +-- wallet. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE key_scopes ( + -- DB ID of the key scope, primary key. Only used for DB level relations. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the wallet this key scope belongs to. + wallet_id BIGINT NOT NULL, + + -- Indicates the BIP standard for the key scope. This is typically will be + -- 84h or 1017h. + purpose BIGINT NOT NULL, + + -- Indicates the coin type for the key scope. This is typically 0 for BTC. + coin_type BIGINT NOT NULL, + + -- Encrypted key used to derive public keys for this scope. + encrypted_coin_pub_key BYTEA NOT NULL, + + -- Reference to the address type used for internal/change addresses. + internal_type_id SMALLINT NOT NULL, + + -- Reference to the address type used for external/receiving addresses. + external_type_id SMALLINT NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if key scopes still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + + -- Foreign key constraints to address types. Using ON DELETE RESTRICT to ensure + -- that the address types cannot be deleted if key scopes still exist. + FOREIGN KEY (internal_type_id) REFERENCES address_types (id) ON DELETE RESTRICT, + FOREIGN KEY (external_type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique index to prevent duplicate key scopes for the same wallet. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin +ON key_scopes (wallet_id, purpose, coin_type); + +-- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. +-- Separated from the main key_scopes table for security and access pattern isolation. +-- Watch-only scopes may have no corresponding row in this table or have NULL +-- encrypted_coin_priv_key. +CREATE TABLE key_scope_secrets ( + -- Reference to the key scope these keys belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + scope_id BIGINT NOT NULL, + + -- Encrypted key used to derive private keys for this scope. + -- NULL for watch-only key scopes. + encrypted_coin_priv_key BYTEA, + + -- Foreign key constraint to key_scopes. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if secrets still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each key scope has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_key_scope_secrets_scope +ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql new file mode 100644 index 0000000000..5f22164220 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS key_scope_secrets; +DROP TABLE IF EXISTS key_scopes; diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql new file mode 100644 index 0000000000..55952cd11b --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -0,0 +1,64 @@ +-- Key Scopes table to store different key scopes (BIP standards) for each +-- wallet. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE key_scopes ( + -- DB ID of the key scope, primary key. Only used for DB level relations. + id INTEGER PRIMARY KEY, + + -- Reference to the wallet this key scope belongs to. + wallet_id INTEGER NOT NULL, + + -- Indicates the BIP standard for the key scope. This is typically will be + -- 84h or 1017h. + purpose INTEGER NOT NULL, + + -- Indicates the coin type for the key scope. This is typically 0 for BTC. + coin_type INTEGER NOT NULL, + + -- Encrypted key used to derive public keys for this scope. + encrypted_coin_pub_key BLOB NOT NULL, + + -- Reference to the address type used for internal/change addresses. + internal_type_id INTEGER NOT NULL, + + -- Reference to the address type used for external/receiving addresses. + external_type_id INTEGER NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if key scopes still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + + -- Foreign key constraints to address types. Using ON DELETE RESTRICT to ensure + -- that the address types cannot be deleted if key scopes still exist. + FOREIGN KEY (internal_type_id) REFERENCES address_types (id) ON DELETE RESTRICT, + FOREIGN KEY (external_type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique index to prevent duplicate key scopes for the same wallet. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin +ON key_scopes (wallet_id, purpose, coin_type); + +-- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. +-- Separated from the main key_scopes table for security and access pattern isolation. +-- Watch-only scopes may have no corresponding row in this table or have NULL +-- encrypted_coin_priv_key. +CREATE TABLE key_scope_secrets ( + -- Reference to the key scope these keys belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + scope_id INTEGER NOT NULL, + + -- Encrypted key used to derive private keys for this scope. + -- NULL for watch-only key scopes. + encrypted_coin_priv_key BLOB, + + -- Foreign key constraint to key_scopes. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if secrets still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each key scope has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_key_scope_secrets_scope +ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 920f8ce7aa..4c49c371e0 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -20,6 +20,21 @@ type Block struct { BlockTimestamp int64 } +type KeyScope struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + +type KeyScopeSecret struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 448a62ca65..d32eca6e07 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -20,6 +20,21 @@ type Block struct { BlockTimestamp int64 } +type KeyScope struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + +type KeyScopeSecret struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + type Wallet struct { ID int64 WalletName string From 99e40ab81c0939faa4b2de1774130cfcb0c1bc7c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 23:24:44 -0300 Subject: [PATCH 045/695] wallet: add key scope SQL queries --- .../db/queries/postgres/key_scopes.sql | 81 +++++++ .../internal/db/queries/sqlite/key_scopes.sql | 81 +++++++ wallet/internal/db/sqlc/postgres/db.go | 144 ++++++++--- .../db/sqlc/postgres/key_scopes.sql.go | 223 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/querier.go | 17 ++ wallet/internal/db/sqlc/sqlite/db.go | 144 ++++++++--- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 223 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/querier.go | 17 ++ 8 files changed, 866 insertions(+), 64 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/key_scopes.sql create mode 100644 wallet/internal/db/queries/sqlite/key_scopes.sql create mode 100644 wallet/internal/db/sqlc/postgres/key_scopes.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/key_scopes.sql.go diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql new file mode 100644 index 0000000000..b1061927b8 --- /dev/null +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -0,0 +1,81 @@ +-- name: CreateKeyScope :one +-- Creates a new key scope for a wallet and returns its ID. +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING id; + +-- name: InsertKeyScopeSecrets :exec +-- Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +-- watch-only scopes. +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + $1, $2 +); + +-- name: GetKeyScopeByID :one +-- Retrieves a key scope by its ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = $1; + +-- name: GetKeyScopeByWalletAndScope :one +-- Retrieves a key scope by wallet ID, purpose, and coin type. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 AND purpose = $2 AND coin_type = $3; + +-- name: ListKeyScopesByWallet :many +-- Lists all key scopes for a wallet, ordered by ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 +ORDER BY id; + +-- name: GetKeyScopeSecrets :one +-- Retrieves the secrets for a key scope. +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = $1; + +-- name: DeleteKeyScopeSecrets :execrows +-- Deletes the secrets for a key scope. +DELETE FROM key_scope_secrets +WHERE scope_id = $1; + +-- name: DeleteKeyScope :execrows +-- Deletes a key scope by its ID. +DELETE FROM key_scopes +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql new file mode 100644 index 0000000000..0385f64685 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -0,0 +1,81 @@ +-- name: CreateKeyScope :one +-- Creates a new key scope for a wallet and returns its ID. +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: InsertKeyScopeSecrets :exec +-- Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +-- watch-only scopes. +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + ?, ? +); + +-- name: GetKeyScopeByID :one +-- Retrieves a key scope by its ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = ?; + +-- name: GetKeyScopeByWalletAndScope :one +-- Retrieves a key scope by wallet ID, purpose, and coin type. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; + +-- name: ListKeyScopesByWallet :many +-- Lists all key scopes for a wallet, ordered by ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? +ORDER BY id; + +-- name: GetKeyScopeSecrets :one +-- Retrieves the secrets for a key scope. +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = ?; + +-- name: DeleteKeyScopeSecrets :execrows +-- Deletes the secrets for a key scope. +DELETE FROM key_scope_secrets +WHERE scope_id = ?; + +-- name: DeleteKeyScope :execrows +-- Deletes a key scope by its ID. +DELETE FROM key_scopes +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 77bc5e5392..870a1598ad 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,18 +24,36 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) + } if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) + } + if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getKeyScopeByIDStmt, err = db.PrepareContext(ctx, GetKeyScopeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByID: %w", err) + } + if q.getKeyScopeByWalletAndScopeStmt, err = db.PrepareContext(ctx, GetKeyScopeByWalletAndScope); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByWalletAndScope: %w", err) + } + if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -48,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -57,6 +78,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -71,6 +95,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createKeyScopeStmt != nil { + if cerr := q.createKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) + } + } if q.createWalletStmt != nil { if cerr := q.createWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createWalletStmt: %w", cerr) @@ -81,6 +110,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteKeyScopeStmt != nil { + if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) + } + } + if q.deleteKeyScopeSecretsStmt != nil { + if cerr := q.deleteKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -91,6 +130,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getKeyScopeByIDStmt != nil { + if cerr := q.getKeyScopeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByIDStmt: %w", cerr) + } + } + if q.getKeyScopeByWalletAndScopeStmt != nil { + if cerr := q.getKeyScopeByWalletAndScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByWalletAndScopeStmt: %w", cerr) + } + } + if q.getKeyScopeSecretsStmt != nil { + if cerr := q.getKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -111,6 +165,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertKeyScopeSecretsStmt != nil { + if cerr := q.insertKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -126,6 +185,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listKeyScopesByWalletStmt != nil { + if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -178,41 +242,57 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go new file mode 100644 index 0000000000..7be4e847c2 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -0,0 +1,223 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: key_scopes.sql + +package sqlcpg + +import ( + "context" +) + +const CreateKeyScope = `-- name: CreateKeyScope :one +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING id +` + +type CreateKeyScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + +// Creates a new key scope for a wallet and returns its ID. +func (q *Queries) CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) { + row := q.queryRow(ctx, q.createKeyScopeStmt, CreateKeyScope, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.EncryptedCoinPubKey, + arg.InternalTypeID, + arg.ExternalTypeID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const DeleteKeyScope = `-- name: DeleteKeyScope :execrows +DELETE FROM key_scopes +WHERE id = $1 +` + +// Deletes a key scope by its ID. +func (q *Queries) DeleteKeyScope(ctx context.Context, id int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeStmt, DeleteKeyScope, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteKeyScopeSecrets = `-- name: DeleteKeyScopeSecrets :execrows +DELETE FROM key_scope_secrets +WHERE scope_id = $1 +` + +// Deletes the secrets for a key scope. +func (q *Queries) DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeSecretsStmt, DeleteKeyScopeSecrets, scopeID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetKeyScopeByID = `-- name: GetKeyScopeByID :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = $1 +` + +// Retrieves a key scope by its ID. +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeByWalletAndScope = `-- name: GetKeyScopeByWalletAndScope :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 AND purpose = $2 AND coin_type = $3 +` + +type GetKeyScopeByWalletAndScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +// Retrieves a key scope by wallet ID, purpose, and coin type. +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeSecrets = `-- name: GetKeyScopeSecrets :one +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = $1 +` + +// Retrieves the secrets for a key scope. +func (q *Queries) GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) { + row := q.queryRow(ctx, q.getKeyScopeSecretsStmt, GetKeyScopeSecrets, scopeID) + var i KeyScopeSecret + err := row.Scan(&i.ScopeID, &i.EncryptedCoinPrivKey) + return i, err +} + +const InsertKeyScopeSecrets = `-- name: InsertKeyScopeSecrets :exec +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + $1, $2 +) +` + +type InsertKeyScopeSecretsParams struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + +// Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +// watch-only scopes. +func (q *Queries) InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error { + _, err := q.exec(ctx, q.insertKeyScopeSecretsStmt, InsertKeyScopeSecrets, arg.ScopeID, arg.EncryptedCoinPrivKey) + return err +} + +const ListKeyScopesByWallet = `-- name: ListKeyScopesByWallet :many +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 +ORDER BY id +` + +// Lists all key scopes for a wallet, ordered by ID. +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { + rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []KeyScope + for rows.Next() { + var i KeyScope + if err := rows.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 30988e20a5..6277126563 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,19 +9,36 @@ import ( ) type Querier interface { + // Creates a new key scope for a wallet and returns its ID. + CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Deletes a key scope by its ID. + DeleteKeyScope(ctx context.Context, id int64) (int64, error) + // Deletes the secrets for a key scope. + DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + // Retrieves a key scope by its ID. + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + // Retrieves a key scope by wallet ID, purpose, and coin type. + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + // Retrieves the secrets for a key scope. + GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for + // watch-only scopes. + InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all key scopes for a wallet, ordered by ID. + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 685bbdd06a..a0079857b9 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,18 +24,36 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) + } if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) + } + if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getKeyScopeByIDStmt, err = db.PrepareContext(ctx, GetKeyScopeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByID: %w", err) + } + if q.getKeyScopeByWalletAndScopeStmt, err = db.PrepareContext(ctx, GetKeyScopeByWalletAndScope); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByWalletAndScope: %w", err) + } + if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -48,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -57,6 +78,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -71,6 +95,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createKeyScopeStmt != nil { + if cerr := q.createKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) + } + } if q.createWalletStmt != nil { if cerr := q.createWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createWalletStmt: %w", cerr) @@ -81,6 +110,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteKeyScopeStmt != nil { + if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) + } + } + if q.deleteKeyScopeSecretsStmt != nil { + if cerr := q.deleteKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -91,6 +130,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getKeyScopeByIDStmt != nil { + if cerr := q.getKeyScopeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByIDStmt: %w", cerr) + } + } + if q.getKeyScopeByWalletAndScopeStmt != nil { + if cerr := q.getKeyScopeByWalletAndScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByWalletAndScopeStmt: %w", cerr) + } + } + if q.getKeyScopeSecretsStmt != nil { + if cerr := q.getKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -111,6 +165,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertKeyScopeSecretsStmt != nil { + if cerr := q.insertKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -126,6 +185,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listKeyScopesByWalletStmt != nil { + if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -178,41 +242,57 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go new file mode 100644 index 0000000000..4223e0789d --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -0,0 +1,223 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: key_scopes.sql + +package sqlcsqlite + +import ( + "context" +) + +const CreateKeyScope = `-- name: CreateKeyScope :one +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type CreateKeyScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + +// Creates a new key scope for a wallet and returns its ID. +func (q *Queries) CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) { + row := q.queryRow(ctx, q.createKeyScopeStmt, CreateKeyScope, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.EncryptedCoinPubKey, + arg.InternalTypeID, + arg.ExternalTypeID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const DeleteKeyScope = `-- name: DeleteKeyScope :execrows +DELETE FROM key_scopes +WHERE id = ? +` + +// Deletes a key scope by its ID. +func (q *Queries) DeleteKeyScope(ctx context.Context, id int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeStmt, DeleteKeyScope, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteKeyScopeSecrets = `-- name: DeleteKeyScopeSecrets :execrows +DELETE FROM key_scope_secrets +WHERE scope_id = ? +` + +// Deletes the secrets for a key scope. +func (q *Queries) DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeSecretsStmt, DeleteKeyScopeSecrets, scopeID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetKeyScopeByID = `-- name: GetKeyScopeByID :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = ? +` + +// Retrieves a key scope by its ID. +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeByWalletAndScope = `-- name: GetKeyScopeByWalletAndScope :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? AND purpose = ? AND coin_type = ? +` + +type GetKeyScopeByWalletAndScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +// Retrieves a key scope by wallet ID, purpose, and coin type. +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeSecrets = `-- name: GetKeyScopeSecrets :one +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = ? +` + +// Retrieves the secrets for a key scope. +func (q *Queries) GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) { + row := q.queryRow(ctx, q.getKeyScopeSecretsStmt, GetKeyScopeSecrets, scopeID) + var i KeyScopeSecret + err := row.Scan(&i.ScopeID, &i.EncryptedCoinPrivKey) + return i, err +} + +const InsertKeyScopeSecrets = `-- name: InsertKeyScopeSecrets :exec +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + ?, ? +) +` + +type InsertKeyScopeSecretsParams struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + +// Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +// watch-only scopes. +func (q *Queries) InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error { + _, err := q.exec(ctx, q.insertKeyScopeSecretsStmt, InsertKeyScopeSecrets, arg.ScopeID, arg.EncryptedCoinPrivKey) + return err +} + +const ListKeyScopesByWallet = `-- name: ListKeyScopesByWallet :many +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? +ORDER BY id +` + +// Lists all key scopes for a wallet, ordered by ID. +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { + rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []KeyScope + for rows.Next() { + var i KeyScope + if err := rows.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index edcbb3cdf7..d489425bc2 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,19 +9,36 @@ import ( ) type Querier interface { + // Creates a new key scope for a wallet and returns its ID. + CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Deletes a key scope by its ID. + DeleteKeyScope(ctx context.Context, id int64) (int64, error) + // Deletes the secrets for a key scope. + DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + // Retrieves a key scope by its ID. + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + // Retrieves a key scope by wallet ID, purpose, and coin type. + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + // Retrieves the secrets for a key scope. + GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for + // watch-only scopes. + InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all key scopes for a wallet, ordered by ID. + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From e773de5b082d11d5720b09502b891c184794a7df Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Dec 2025 11:47:09 +0000 Subject: [PATCH 046/695] wallet: update blocks queries to handle race condition --- wallet/internal/db/queries/postgres/blocks.sql | 3 ++- wallet/internal/db/queries/sqlite/blocks.sql | 2 +- wallet/internal/db/sqlc/postgres/blocks.sql.go | 1 + wallet/internal/db/sqlc/sqlite/blocks.sql.go | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql index daa8e13e89..050e85c253 100644 --- a/wallet/internal/db/queries/postgres/blocks.sql +++ b/wallet/internal/db/queries/postgres/blocks.sql @@ -8,7 +8,8 @@ WHERE block_height = $1; -- name: InsertBlock :exec INSERT INTO blocks (block_height, header_hash, block_timestamp) -VALUES ($1, $2, $3); +VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO NOTHING; -- name: DeleteBlock :exec DELETE FROM blocks diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql index 077e33c897..003d6d3e83 100644 --- a/wallet/internal/db/queries/sqlite/blocks.sql +++ b/wallet/internal/db/queries/sqlite/blocks.sql @@ -7,7 +7,7 @@ FROM blocks WHERE block_height = ?; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, block_timestamp) +INSERT OR IGNORE INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go index c95f43abdb..b5f83978cb 100644 --- a/wallet/internal/db/sqlc/postgres/blocks.sql.go +++ b/wallet/internal/db/sqlc/postgres/blocks.sql.go @@ -38,6 +38,7 @@ func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Bloc const InsertBlock = `-- name: InsertBlock :exec INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO NOTHING ` type InsertBlockParams struct { diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go index f5eb1d3e00..decb094d32 100644 --- a/wallet/internal/db/sqlc/sqlite/blocks.sql.go +++ b/wallet/internal/db/sqlc/sqlite/blocks.sql.go @@ -36,7 +36,7 @@ func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Bloc } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, block_timestamp) +INSERT OR IGNORE INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) ` From 32cb6be83824b89f4b1a614408c03dbed5fc7e69 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 18 Dec 2025 05:37:08 +0000 Subject: [PATCH 047/695] wallet: integrate relevant blocks queries --- wallet/internal/db/block_pg.go | 26 ++++++++ wallet/internal/db/block_sqlite.go | 24 ++++++++ wallet/internal/db/itest/wallet_store_test.go | 61 +++++++++++++++++++ wallet/internal/db/wallet_pg.go | 19 ++++++ wallet/internal/db/wallet_sqlite.go | 21 +++++++ 5 files changed, 151 insertions(+) diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index 2cd05f3608..e49647dd50 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -1,8 +1,11 @@ package db import ( + "context" "database/sql" "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // buildPgBlock constructs a Block from the given PostgreSQL block @@ -17,3 +20,26 @@ func buildPgBlock(height sql.NullInt32, hash []byte, return buildBlock(hash, height32, timestamp.Int64) } + +// ensureBlockExistsPg ensures that a block exists in the database. +func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, + block *Block) error { + + height, err := uint32ToInt32(block.Height) + if err != nil { + return fmt.Errorf("convert block height: %w", err) + } + + blockParams := sqlcpg.InsertBlockParams{ + BlockHeight: height, + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + } + + err = qtx.InsertBlock(ctx, blockParams) + if err != nil { + return fmt.Errorf("insert block: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index 7df790749c..8fd83c22bf 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -1,8 +1,11 @@ package db import ( + "context" "database/sql" "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // buildSqliteBlock constructs a Block from the given SQLite block @@ -17,3 +20,24 @@ func buildSqliteBlock(height sql.NullInt64, hash []byte, return buildBlock(hash, height32, timestamp.Int64) } + +// ensureBlockExistsSqlite ensures that a block exists in the database. If it +// doesn't exist, it inserts it. +func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + block *Block) error { + + height := int64(block.Height) + + blockParams := sqlcsqlite.InsertBlockParams{ + BlockHeight: height, + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + } + + err := qtx.InsertBlock(ctx, blockParams) + if err != nil { + return fmt.Errorf("insert block: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 0af162cc41..a311fc2997 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -358,3 +358,64 @@ func TestUpdateWalletSecrets(t *testing.T) { require.NoError(t, err) require.Equal(t, newSecrets.EncryptedMasterHdPrivKey, seed) } + +// TestUpdateWallet_AutoBlockInsertion verifies that UpdateWallet automatically +// inserts blocks when updating SyncedTo or BirthdayBlock. +func TestUpdateWallet_AutoBlockInsertion(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("auto-block-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Create a block WITHOUT pre-inserting it into the blocks table. + block := db.Block{ + Height: uint32(100), + Hash: RandomHash(), + Timestamp: time.Now().UTC(), + } + + // Update wallet with SyncedTo - should automatically insert the block. + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + SyncedTo: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + // Verify the wallet was updated. + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + require.Equal(t, block.Hash, retrieved.SyncedTo.Hash) + + // Update again with the same block - should be idempotent. + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err, "updating with same block should be idempotent") + + // Create another block for BirthdayBlock. + birthdayBlock := db.Block{ + Height: uint32(50), + Hash: RandomHash(), + Timestamp: time.Now().UTC().Add(-time.Hour), + } + + // Update wallet with BirthdayBlock - should automatically insert it. + updateParams = db.UpdateWalletParams{ + WalletID: created.ID, + BirthdayBlock: &birthdayBlock, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + // Verify both blocks are set. + retrieved, err = store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.NotNil(t, retrieved.BirthdayBlock) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + require.Equal(t, birthdayBlock.Height, retrieved.BirthdayBlock.Height) +} diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index e6f1b54e95..f7fd073ed3 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -185,6 +185,25 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + // Insert blocks if needed. + if params.SyncedTo != nil { + err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) + if err != nil { + return fmt.Errorf("ensure synced block: %w", + err) + } + } + + if params.BirthdayBlock != nil { + err := ensureBlockExistsPg( + ctx, qtx, params.BirthdayBlock, + ) + if err != nil { + return fmt.Errorf("ensure birthday block: %w", + err) + } + } + // Build sync state update params directly from input. // Only set fields that are being updated (leave others as nil). syncParams := sqlcpg.UpdateWalletSyncStateParams{ diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 732242cb08..ad4b69bb12 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -185,6 +185,27 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + // Insert blocks if needed. + if params.SyncedTo != nil { + err := ensureBlockExistsSqlite( + ctx, qtx, params.SyncedTo, + ) + if err != nil { + return fmt.Errorf("ensure synced block: %w", + err) + } + } + + if params.BirthdayBlock != nil { + err := ensureBlockExistsSqlite( + ctx, qtx, params.BirthdayBlock, + ) + if err != nil { + return fmt.Errorf("ensure birthday block: %w", + err) + } + } + // Build sync state update params directly from input. // Only set fields that are being updated (leave others as nil). syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ From d2add149485d72d867e0eaf69413152ded62c326 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 18 Dec 2025 05:51:50 +0000 Subject: [PATCH 048/695] wallet: refactor wallet sync params update Fixes linting complains "calculated cyclomatic complexity for function UpdateWallet is 12, max is 10 (cyclop)" --- wallet/internal/db/wallet_pg.go | 75 ++++++++++++++++------------- wallet/internal/db/wallet_sqlite.go | 60 +++++++++++++---------- 2 files changed, 76 insertions(+), 59 deletions(-) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index f7fd073ed3..f84a291ce1 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -204,39 +204,9 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } } - // Build sync state update params directly from input. - // Only set fields that are being updated (leave others as nil). - syncParams := sqlcpg.UpdateWalletSyncStateParams{ - WalletID: int64(params.WalletID), - } - - if params.SyncedTo != nil { - syncedHeight, err := uint32ToNullInt32( - params.SyncedTo.Height, - ) - if err != nil { - return err - } - - syncParams.SyncedHeight = syncedHeight - } - - if params.Birthday != nil { - syncParams.BirthdayTimestamp = sql.NullTime{ - Time: *params.Birthday, - Valid: true, - } - } - - if params.BirthdayBlock != nil { - birthdayHeight, err := uint32ToNullInt32( - params.BirthdayBlock.Height, - ) - if err != nil { - return err - } - - syncParams.BirthdayHeight = birthdayHeight + syncParams, err := buildUpdateSyncParamsPg(params) + if err != nil { + return err } rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) @@ -369,3 +339,42 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { return info, nil } + +// buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from +// the given UpdateWalletParams. +func buildUpdateSyncParamsPg(params UpdateWalletParams) ( + sqlcpg.UpdateWalletSyncStateParams, error) { + + syncParams := sqlcpg.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncedHeight, err := uint32ToNullInt32(params.SyncedTo.Height) + if err != nil { + return syncParams, err + } + + syncParams.SyncedHeight = syncedHeight + } + + if params.Birthday != nil { + syncParams.BirthdayTimestamp = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + + if params.BirthdayBlock != nil { + birthdayHeight, err := uint32ToNullInt32( + params.BirthdayBlock.Height, + ) + if err != nil { + return syncParams, err + } + + syncParams.BirthdayHeight = birthdayHeight + } + + return syncParams, nil +} diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index ad4b69bb12..e7d7ff45ff 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -206,32 +206,7 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } } - // Build sync state update params directly from input. - // Only set fields that are being updated (leave others as nil). - syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ - WalletID: int64(params.WalletID), - } - - if params.SyncedTo != nil { - syncParams.SyncedHeight = sql.NullInt64{ - Int64: int64(params.SyncedTo.Height), - Valid: true, - } - } - - if params.Birthday != nil { - syncParams.BirthdayTimestamp = sql.NullTime{ - Time: *params.Birthday, - Valid: true, - } - } - - if params.BirthdayBlock != nil { - syncParams.BirthdayHeight = sql.NullInt64{ - Int64: int64(params.BirthdayBlock.Height), - Valid: true, - } - } + syncParams := buildUpdateSyncParamsSqlite(params) rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) if err != nil { @@ -368,3 +343,36 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { return info, nil } + +// buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from +// the given UpdateWalletParams. +func buildUpdateSyncParamsSqlite( + params UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + + syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncParams.SyncedHeight = sql.NullInt64{ + Int64: int64(params.SyncedTo.Height), + Valid: true, + } + } + + if params.Birthday != nil { + syncParams.BirthdayTimestamp = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), + Valid: true, + } + } + + return syncParams +} From bb5a67bcbcdd1fbc694c4e5e6eb9b921970149e1 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 10:48:48 -0300 Subject: [PATCH 049/695] wallet: add accounts SQL migrations --- .../postgres/000004_key_scopes.up.sql | 13 ++- .../postgres/000005_accounts.down.sql | 5 + .../postgres/000005_accounts.up.sql | 107 ++++++++++++++++++ .../sqlite/000004_key_scopes.up.sql | 13 ++- .../sqlite/000005_accounts.down.sql | 5 + .../migrations/sqlite/000005_accounts.up.sql | 107 ++++++++++++++++++ .../db/sqlc/postgres/key_scopes.sql.go | 44 +++++-- wallet/internal/db/sqlc/postgres/models.go | 23 ++++ wallet/internal/db/sqlc/postgres/querier.go | 6 +- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 44 +++++-- wallet/internal/db/sqlc/sqlite/models.go | 23 ++++ wallet/internal/db/sqlc/sqlite/querier.go | 6 +- 12 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 wallet/internal/db/migrations/postgres/000005_accounts.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000005_accounts.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000005_accounts.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index e7165cdcc0..9af8cabd0a 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -17,8 +17,9 @@ CREATE TABLE key_scopes ( -- Indicates the coin type for the key scope. This is typically 0 for BTC. coin_type BIGINT NOT NULL, - -- Encrypted key used to derive public keys for this scope. - encrypted_coin_pub_key BYTEA NOT NULL, + -- Encrypted key used to derive public keys for this scope in imported + -- accounts. + encrypted_coin_pub_key BYTEA, -- Reference to the address type used for internal/change addresses. internal_type_id SMALLINT NOT NULL, @@ -26,6 +27,14 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id SMALLINT NOT NULL, + -- Counter used to allocate sequential account numbers within this scope. + -- This avoids scanning the accounts table to compute MAX(account_number). + -- The value is updated atomically via UPDATE with RETURNING, which allows + -- concurrent account creation without additional locking logic. + -- The counter starts at minus one. Each new account consumes the current + -- value plus one, then stores the updated value for the next allocation. + last_account_number BIGINT NOT NULL DEFAULT -1, + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.down.sql b/wallet/internal/db/migrations/postgres/000005_accounts.down.sql new file mode 100644 index 0000000000..d7b3097d8b --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000005_accounts.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS account_secrets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS account_origins; diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql new file mode 100644 index 0000000000..c51ea1f95e --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -0,0 +1,107 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Account origin lookup table - provides standardized descriptions for wallet +-- account origins. This is a reference table that maps the AccountOrigin enum +-- values used in Go code to their human-readable names. +CREATE TABLE account_origins ( + -- Primary key matching the Go AccountOrigin enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id SMALLINT PRIMARY KEY, + + -- Human-readable account origin description. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_account_origins_description +ON account_origins (description); + +-- Seed reference data matching the Go AccountOrigin enum constants. +-- These values are static and represent the account origin types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO account_origins (id, description) VALUES +(0, 'derived'), -- Derived from a hierarchical deterministic key. +(1, 'imported'); -- Imported from an external source. + +-- Accounts table stores wallet accounts under each key scope (BIP32/BIP44 +-- hierarchy). Each account represents either a derived HD account following +-- BIP44 derivation paths (m/purpose'/coin'/account') or an imported account +-- from an external source (e.g., hardware wallet, watch-only xpub). +-- +-- The table supports both account types through nullable account_number: +-- - Derived accounts have sequential account_number values (0, 1, 2, ...) +-- - Imported accounts have NULL account_number (not part of BIP44 hierarchy) +CREATE TABLE accounts ( + -- DB ID of the account, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the key scope this account belongs to. + scope_id BIGINT NOT NULL, + + -- Account number described in BIP44. NULL for imported accounts since they + -- don't follow the BIP44 derivation path. + account_number BIGINT, + + -- Human friendly name for the account. + account_name TEXT NOT NULL, + + -- Reference to the origin of the account. + origin_id SMALLINT NOT NULL, + + -- Defines if the account is watch-only. + is_watch_only BOOLEAN NOT NULL, + + -- Master fingerprint is the fingerprint of the master pub key that created + -- this account. + master_fingerprint BIGINT, + + -- Encrypted public key for the account. + encrypted_public_key BYTEA, + + -- Timestamp when the account was created. Automatically set by the database. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if accounts still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + + -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to + -- ensure that the origin cannot be deleted if accounts still exist. + FOREIGN KEY (origin_id) REFERENCES account_origins (id) ON DELETE RESTRICT +); + +-- Index on foreign scope_id for faster lookups and joins. +CREATE INDEX idx_accounts_scope ON accounts (scope_id); + +-- Unique partial index to prevent duplicate account numbers within the same +-- key scope. Only enforced for non-NULL account numbers (derived accounts). +-- Imported accounts have NULL account_number and are excluded from this +-- constraint. +CREATE UNIQUE INDEX uidx_accounts_scope_account_number +ON accounts (scope_id, account_number) +WHERE account_number IS NOT NULL; + +-- Unique index to prevent duplicate account names within the same key scope. +CREATE UNIQUE INDEX uidx_accounts_scope_account_name +ON accounts (scope_id, account_name); + +-- Account Secrets table to hold encrypted account-level secrets. +CREATE TABLE account_secrets ( + -- Reference to the account these keys belong to. + account_id BIGINT NOT NULL, + + -- Encrypted private key for the account. Watch-only accounts may have + -- no row in this table. + encrypted_private_key BYTEA NOT NULL, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if secrets still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between account and its secrets. +CREATE UNIQUE INDEX uidx_account_secrets_account +ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 55952cd11b..37432efdfc 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -17,8 +17,9 @@ CREATE TABLE key_scopes ( -- Indicates the coin type for the key scope. This is typically 0 for BTC. coin_type INTEGER NOT NULL, - -- Encrypted key used to derive public keys for this scope. - encrypted_coin_pub_key BLOB NOT NULL, + -- Encrypted key used to derive public keys for this scope in imported + -- accounts. + encrypted_coin_pub_key BLOB, -- Reference to the address type used for internal/change addresses. internal_type_id INTEGER NOT NULL, @@ -26,6 +27,14 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id INTEGER NOT NULL, + -- Counter used to allocate sequential account numbers within this scope. + -- This avoids scanning the accounts table to compute MAX(account_number). + -- The value is updated atomically via UPDATE with RETURNING, which allows + -- concurrent account creation without additional locking logic. + -- The counter starts at minus one. Each new account consumes the current + -- value plus one, then stores the updated value for the next allocation. + last_account_number INTEGER NOT NULL DEFAULT -1, + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql new file mode 100644 index 0000000000..d7b3097d8b --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS account_secrets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS account_origins; diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql new file mode 100644 index 0000000000..0644de5ae4 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -0,0 +1,107 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Account origin lookup table - provides standardized descriptions for wallet +-- account origins. This is a reference table that maps the AccountOrigin enum +-- values used in Go code to their human-readable names. +CREATE TABLE account_origins ( + -- Primary key matching the Go AccountOrigin enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id INTEGER PRIMARY KEY, + + -- Human-readable account origin description. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_account_origins_description +ON account_origins (description); + +-- Seed reference data matching the Go AccountOrigin enum constants. +-- These values are static and represent the account origin types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO account_origins (id, description) VALUES +(0, 'derived'), -- Derived from a hierarchical deterministic key. +(1, 'imported'); -- Imported from an external source. + +-- Accounts table stores wallet accounts under each key scope (BIP32/BIP44 +-- hierarchy). Each account represents either a derived HD account following +-- BIP44 derivation paths (m/purpose'/coin'/account') or an imported account +-- from an external source (e.g., hardware wallet, watch-only xpub). +-- +-- The table supports both account types through nullable account_number: +-- - Derived accounts have sequential account_number values (0, 1, 2, ...) +-- - Imported accounts have NULL account_number (not part of BIP44 hierarchy) +CREATE TABLE accounts ( + -- DB ID of the account, primary key. + id INTEGER PRIMARY KEY, + + -- Reference to the key scope this account belongs to. + scope_id INTEGER NOT NULL, + + -- Account number described in BIP44. NULL for imported accounts since they + -- don't follow the BIP44 derivation path. + account_number INTEGER, + + -- Human friendly name for the account. + account_name TEXT NOT NULL, + + -- Reference to the origin of the account. + origin_id INTEGER NOT NULL, + + -- Defines if the account is watch-only. + is_watch_only BOOLEAN NOT NULL, + + -- Master fingerprint is the fingerprint of the master pub key that created + -- this account. + master_fingerprint INTEGER, + + -- Encrypted public key for the account. + encrypted_public_key BLOB, + + -- Timestamp when the account was created. Automatically set by the database. + created_at DATETIME NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if accounts still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + + -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to + -- ensure that the origin cannot be deleted if accounts still exist. + FOREIGN KEY (origin_id) REFERENCES account_origins (id) ON DELETE RESTRICT +); + +-- Index on foreign scope_id for faster lookups and joins. +CREATE INDEX idx_accounts_scope ON accounts (scope_id); + +-- Unique partial index to prevent duplicate account numbers within the same +-- key scope. Only enforced for non-NULL account numbers (derived accounts). +-- Imported accounts have NULL account_number and are excluded from this +-- constraint. +CREATE UNIQUE INDEX uidx_accounts_scope_account_number +ON accounts (scope_id, account_number) +WHERE account_number IS NOT NULL; + +-- Unique index to prevent duplicate account names within the same key scope. +CREATE UNIQUE INDEX uidx_accounts_scope_account_name +ON accounts (scope_id, account_name); + +-- Account Secrets table to hold encrypted account-level secrets. +CREATE TABLE account_secrets ( + -- Reference to the account these keys belong to. + account_id INTEGER NOT NULL, + + -- Encrypted private key for the account. Watch-only accounts may have + -- no row in this table. + encrypted_private_key BLOB NOT NULL, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if secrets still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between account and its secrets. +CREATE UNIQUE INDEX uidx_account_secrets_account +ON account_secrets (account_id); diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 7be4e847c2..121df96ffe 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -88,10 +88,20 @@ FROM key_scopes WHERE id = $1 ` +type GetKeyScopeByIDRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i KeyScope + var i GetKeyScopeByIDRow err := row.Scan( &i.ID, &i.WalletID, @@ -123,10 +133,20 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } +type GetKeyScopeByWalletAndScopeRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i KeyScope + var i GetKeyScopeByWalletAndScopeRow err := row.Scan( &i.ID, &i.WalletID, @@ -190,16 +210,26 @@ WHERE wallet_id = $1 ORDER BY id ` +type ListKeyScopesByWalletRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []KeyScope + var items []ListKeyScopesByWalletRow for rows.Next() { - var i KeyScope + var i ListKeyScopesByWalletRow if err := rows.Scan( &i.ID, &i.WalletID, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 4c49c371e0..2957b82a6e 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -9,6 +9,28 @@ import ( "time" ) +type Account struct { + ID int64 + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + MasterFingerprint sql.NullInt64 + EncryptedPublicKey []byte + CreatedAt time.Time +} + +type AccountOrigin struct { + ID int16 + Description string +} + +type AccountSecret struct { + AccountID int64 + EncryptedPrivateKey []byte +} + type AddressType struct { ID int16 Description string @@ -28,6 +50,7 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int16 ExternalTypeID int16 + LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 6277126563..57c00a5a20 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -21,9 +21,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -38,7 +38,7 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index 4223e0789d..241da81ea9 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -88,10 +88,20 @@ FROM key_scopes WHERE id = ? ` +type GetKeyScopeByIDRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i KeyScope + var i GetKeyScopeByIDRow err := row.Scan( &i.ID, &i.WalletID, @@ -123,10 +133,20 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } +type GetKeyScopeByWalletAndScopeRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i KeyScope + var i GetKeyScopeByWalletAndScopeRow err := row.Scan( &i.ID, &i.WalletID, @@ -190,16 +210,26 @@ WHERE wallet_id = ? ORDER BY id ` +type ListKeyScopesByWalletRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []KeyScope + var items []ListKeyScopesByWalletRow for rows.Next() { - var i KeyScope + var i ListKeyScopesByWalletRow if err := rows.Scan( &i.ID, &i.WalletID, diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index d32eca6e07..47199cd608 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -9,6 +9,28 @@ import ( "time" ) +type Account struct { + ID int64 + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + MasterFingerprint sql.NullInt64 + EncryptedPublicKey []byte + CreatedAt time.Time +} + +type AccountOrigin struct { + ID int64 + Description string +} + +type AccountSecret struct { + AccountID int64 + EncryptedPrivateKey []byte +} + type AddressType struct { ID int64 Description string @@ -28,6 +50,7 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int64 ExternalTypeID int64 + LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index d489425bc2..27e37f69b7 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -21,9 +21,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -38,7 +38,7 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From 132ba5b512677f5ea71f9031e078959399918549 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 10:56:31 -0300 Subject: [PATCH 050/695] wallet: add accounts SQL queries --- .../internal/db/queries/postgres/accounts.sql | 235 ++++++ .../db/queries/postgres/key_scopes.sql | 1 + .../internal/db/queries/sqlite/accounts.sql | 229 ++++++ .../internal/db/queries/sqlite/key_scopes.sql | 12 + .../internal/db/sqlc/postgres/accounts.sql.go | 715 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 236 ++++-- .../db/sqlc/postgres/key_scopes.sql.go | 1 + wallet/internal/db/sqlc/postgres/querier.go | 37 + .../internal/db/sqlc/sqlite/accounts.sql.go | 711 +++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 246 ++++-- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 25 + wallet/internal/db/sqlc/sqlite/querier.go | 50 ++ 12 files changed, 2402 insertions(+), 96 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/accounts.sql create mode 100644 wallet/internal/db/queries/sqlite/accounts.sql create mode 100644 wallet/internal/db/sqlc/postgres/accounts.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/accounts.sql.go diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql new file mode 100644 index 0000000000..bf5f02ed9f --- /dev/null +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -0,0 +1,235 @@ +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, allocating a fresh +-- sequential account number from key_scopes.last_account_number. +-- The allocation is atomic: the UPDATE takes the row lock on the scope row, +-- returns the allocated number, and updates the counter for the next call. +WITH allocated_number AS ( + UPDATE key_scopes + SET last_account_number = last_account_number + 1 + WHERE key_scopes.id = $1 + RETURNING key_scopes.id, last_account_number AS account_number +) + +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +SELECT + allocated_number.id AS scope_id, + allocated_number.account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM allocated_number +RETURNING accounts.id, accounts.account_number, accounts.created_at; + +-- name: CreateImportedAccount :one +-- Creates a new imported account under the given scope with NULL account +-- number. Imported accounts don't follow BIP44 derivation, so they don't need +-- a sequential account number. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ($1, NULL, $2, $3, $4, $5, $6) +RETURNING id, created_at; + +-- name: CreateAccountSecret :exec +-- Inserts the encrypted private key material for an account. +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + $1, $2 +); + +-- name: GetAccountByScopeAndName :one +-- Returns a single account by scope id and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_name = $2; + +-- name: GetAccountByScopeAndNumber :one +-- Returns a single account by scope id and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_number = $2; + +-- name: GetAccountByWalletScopeAndName :one +-- Returns a single account by wallet id, scope tuple, and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_name = $4; + +-- name: GetAccountByWalletScopeAndNumber :one +-- Returns a single account by wallet id, scope tuple, and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_number = $4; + +-- name: GetAccountPropsById :one +-- Returns full account properties by account id. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = $1; + +-- name: ListAccountsByScope :many +-- Lists all accounts in a scope, ordered by account number. Imported accounts +-- (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWalletScope :many +-- Lists all accounts for a wallet and scope tuple, ordered by account number. +-- Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWalletAndName :many +-- Lists all accounts for a wallet filtered by account name, ordered by account +-- number. Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 AND a.account_name = $2 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWallet :many +-- Lists all accounts for a wallet, ordered by account number. Imported +-- accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 +ORDER BY a.account_number NULLS LAST; + +-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +-- Renames an account identified by wallet id, scope tuple, and account number. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_number = sqlc.arg(account_number); + +-- name: UpdateAccountNameByWalletScopeAndName :execrows +-- Renames an account identified by wallet id, scope tuple, and current account name. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_name = sqlc.arg(old_name); diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index b1061927b8..5b9d31fb01 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -10,6 +10,7 @@ INSERT INTO key_scopes ( ) VALUES ( $1, $2, $3, $4, $5, $6 ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id; -- name: InsertKeyScopeSecrets :exec diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql new file mode 100644 index 0000000000..5d033dec88 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -0,0 +1,229 @@ +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, using a caller-provided +-- account number. +-- +-- NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation +-- step with an INSERT in a single CTE. +-- +-- We instead: +-- 1) call AllocateAccountNumber (key_scopes.sql) +-- 2) call CreateDerivedAccount with the returned number +-- +-- Both statements run within the same SQL transaction. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id, account_number, created_at; + +-- name: CreateImportedAccount :one +-- Creates a new imported account under the given scope with NULL account +-- number. Imported accounts don't follow BIP44 derivation, so they don't need +-- a sequential account number. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES (?, NULL, ?, ?, ?, ?, ?) +RETURNING id, created_at; + +-- name: CreateAccountSecret :exec +-- Inserts the encrypted private key material for an account. +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + ?, ? +); + +-- name: GetAccountByScopeAndName :one +-- Returns a single account by scope id and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_name = ?; + +-- name: GetAccountByScopeAndNumber :one +-- Returns a single account by scope id and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_number = ?; + +-- name: GetAccountByWalletScopeAndName :one +-- Returns a single account by wallet id, scope tuple, and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_name = ?; + +-- name: GetAccountByWalletScopeAndNumber :one +-- Returns a single account by wallet id, scope tuple, and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_number = ?; + +-- name: GetAccountPropsById :one +-- Returns full account properties by account id. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = ?; + +-- name: ListAccountsByScope :many +-- Lists all accounts in a scope, ordered by account number. Imported accounts +-- (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWalletScope :many +-- Lists all accounts for a wallet and scope tuple, ordered by account number. +-- Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWalletAndName :many +-- Lists all accounts for a wallet filtered by account name, ordered by account +-- number. Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? AND a.account_name = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWallet :many +-- Lists all accounts for a wallet, ordered by account number. Imported +-- accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +-- Renames an account identified by wallet id, scope tuple, and account number. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_number = sqlc.arg(account_number); + +-- name: UpdateAccountNameByWalletScopeAndName :execrows +-- Renames an account identified by wallet id, scope tuple, and current account name. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_name = sqlc.arg(old_name); diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 0385f64685..1635336b66 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -10,6 +10,7 @@ INSERT INTO key_scopes ( ) VALUES ( ?, ?, ?, ?, ?, ? ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id; -- name: InsertKeyScopeSecrets :exec @@ -48,6 +49,17 @@ SELECT FROM key_scopes WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; +-- name: AllocateAccountNumber :one +-- Atomically allocates the next account number for a key scope. +-- Returns the scope_id and the allocated account number. +-- SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single +-- CTE (unlike Postgres). So, we call this first, then pass the returned number +-- to CreateAccount, within the same SQL transaction. +UPDATE key_scopes +SET last_account_number = last_account_number + 1 +WHERE id = ? +RETURNING id, last_account_number; + -- name: ListKeyScopesByWallet :many -- Lists all key scopes for a wallet, ordered by ID. SELECT diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go new file mode 100644 index 0000000000..b5b5de9ade --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -0,0 +1,715 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: accounts.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const CreateAccountSecret = `-- name: CreateAccountSecret :exec +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + $1, $2 +) +` + +type CreateAccountSecretParams struct { + AccountID int64 + EncryptedPrivateKey []byte +} + +// Inserts the encrypted private key material for an account. +func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error { + _, err := q.exec(ctx, q.createAccountSecretStmt, CreateAccountSecret, arg.AccountID, arg.EncryptedPrivateKey) + return err +} + +const CreateDerivedAccount = `-- name: CreateDerivedAccount :one +WITH allocated_number AS ( + UPDATE key_scopes + SET last_account_number = last_account_number + 1 + WHERE key_scopes.id = $1 + RETURNING key_scopes.id, last_account_number AS account_number +) + +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +SELECT + allocated_number.id AS scope_id, + allocated_number.account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM allocated_number +RETURNING accounts.id, accounts.account_number, accounts.created_at +` + +type CreateDerivedAccountParams struct { + ID int64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateDerivedAccountRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Creates a new derived account under the given scope, allocating a fresh +// sequential account number from key_scopes.last_account_number. +// The allocation is atomic: the UPDATE takes the row lock on the scope row, +// returns the allocated number, and updates the counter for the next call. +func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, + arg.ID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + +const CreateImportedAccount = `-- name: CreateImportedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ($1, NULL, $2, $3, $4, $5, $6) +RETURNING id, created_at +` + +type CreateImportedAccountParams struct { + ScopeID int64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateImportedAccountRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a new imported account under the given scope with NULL account +// number. Imported accounts don't follow BIP44 derivation, so they don't need +// a sequential account number. +func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { + row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, + arg.ScopeID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateImportedAccountRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_name = $2 +` + +type GetAccountByScopeAndNameParams struct { + ScopeID int64 + AccountName string +} + +type GetAccountByScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account name. +func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) + var i GetAccountByScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_number = $2 +` + +type GetAccountByScopeAndNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account number. +func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) + var i GetAccountByScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_name = $4 +` + +type GetAccountByWalletScopeAndNameParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type GetAccountByWalletScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account name. +func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNameStmt, GetAccountByWalletScopeAndName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + var i GetAccountByWalletScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_number = $4 +` + +type GetAccountByWalletScopeAndNumberParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByWalletScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account number. +func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNumberStmt, GetAccountByWalletScopeAndNumber, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + var i GetAccountByWalletScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountPropsById = `-- name: GetAccountPropsById :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = $1 +` + +type GetAccountPropsByIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 +} + +// Returns full account properties by account id. +func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByIdStmt, GetAccountPropsById, id) + var i GetAccountPropsByIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.EncryptedPublicKey, + &i.MasterFingerprint, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const ListAccountsByScope = `-- name: ListAccountsByScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts in a scope, ordered by account number. Imported accounts +// (with NULL account_number) appear last. +func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByScopeStmt, ListAccountsByScope, scopeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByScopeRow + for rows.Next() { + var i ListAccountsByScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWallet = `-- name: ListAccountsByWallet :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet, ordered by account number. Imported +// accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletStmt, ListAccountsByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletRow + for rows.Next() { + var i ListAccountsByWalletRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 AND a.account_name = $2 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletAndNameParams struct { + WalletID int64 + AccountName string +} + +type ListAccountsByWalletAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet filtered by account name, ordered by account +// number. Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletAndNameStmt, ListAccountsByWalletAndName, arg.WalletID, arg.AccountName) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletAndNameRow + for rows.Next() { + var i ListAccountsByWalletAndNameRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +type ListAccountsByWalletScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet and scope tuple, ordered by account number. +// Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletScopeStmt, ListAccountsByWalletScope, arg.WalletID, arg.Purpose, arg.CoinType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletScopeRow + for rows.Next() { + var i ListAccountsByWalletScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows +UPDATE accounts +SET account_name = $1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = $2 + AND purpose = $3 + AND coin_type = $4 + ) + AND account_name = $5 +` + +type UpdateAccountNameByWalletScopeAndNameParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + OldName string +} + +// Renames an account identified by wallet id, scope tuple, and current account name. +func (q *Queries) UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNameStmt, UpdateAccountNameByWalletScopeAndName, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.OldName, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateAccountNameByWalletScopeAndNumber = `-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +UPDATE accounts +SET account_name = $1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = $2 + AND purpose = $3 + AND coin_type = $4 + ) + AND account_number = $5 +` + +type UpdateAccountNameByWalletScopeAndNumberParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +// Renames an account identified by wallet id, scope tuple, and account number. +func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNumberStmt, UpdateAccountNameByWalletScopeAndNumber, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 870a1598ad..22925ed29e 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,6 +24,15 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) + } + if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) + } + if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -39,6 +48,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) + } + if q.getAccountByScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndNumber: %w", err) + } + if q.getAccountByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndName: %w", err) + } + if q.getAccountByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndNumber: %w", err) + } + if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -75,6 +99,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAccountsByScopeStmt, err = db.PrepareContext(ctx, ListAccountsByScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByScope: %w", err) + } + if q.listAccountsByWalletStmt, err = db.PrepareContext(ctx, ListAccountsByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWallet: %w", err) + } + if q.listAccountsByWalletAndNameStmt, err = db.PrepareContext(ctx, ListAccountsByWalletAndName); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletAndName: %w", err) + } + if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -84,6 +120,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) + } + if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -95,6 +137,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createAccountSecretStmt != nil { + if cerr := q.createAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) + } + } + if q.createDerivedAccountStmt != nil { + if cerr := q.createDerivedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) + } + } + if q.createImportedAccountStmt != nil { + if cerr := q.createImportedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -120,6 +177,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.getAccountByScopeAndNameStmt != nil { + if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByScopeAndNumberStmt != nil { + if cerr := q.getAccountByScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNameStmt != nil { + if cerr := q.getAccountByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNumberStmt != nil { + if cerr := q.getAccountByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountPropsByIdStmt != nil { + if cerr := q.getAccountPropsByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -180,6 +262,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAccountsByScopeStmt != nil { + if cerr := q.listAccountsByScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByScopeStmt: %w", cerr) + } + } + if q.listAccountsByWalletStmt != nil { + if cerr := q.listAccountsByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletStmt: %w", cerr) + } + } + if q.listAccountsByWalletAndNameStmt != nil { + if cerr := q.listAccountsByWalletAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletAndNameStmt: %w", cerr) + } + } + if q.listAccountsByWalletScopeStmt != nil { + if cerr := q.listAccountsByWalletScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -195,6 +297,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.updateAccountNameByWalletScopeAndNameStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.updateAccountNameByWalletScopeAndNumberStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -242,57 +354,85 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createKeyScopeStmt *sql.Stmt - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - deleteKeyScopeStmt *sql.Stmt - deleteKeyScopeSecretsStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getKeyScopeByIDStmt *sql.Stmt - getKeyScopeByWalletAndScopeStmt *sql.Stmt - getKeyScopeSecretsStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertKeyScopeSecretsStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listKeyScopesByWalletStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createAccountSecretStmt *sql.Stmt + createDerivedAccountStmt *sql.Stmt + createImportedAccountStmt *sql.Stmt + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAccountByScopeAndNameStmt *sql.Stmt + getAccountByScopeAndNumberStmt *sql.Stmt + getAccountByWalletScopeAndNameStmt *sql.Stmt + getAccountByWalletScopeAndNumberStmt *sql.Stmt + getAccountPropsByIdStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAccountsByScopeStmt *sql.Stmt + listAccountsByWalletStmt *sql.Stmt + listAccountsByWalletAndNameStmt *sql.Stmt + listAccountsByWalletScopeStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateAccountNameByWalletScopeAndNameStmt *sql.Stmt + updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createKeyScopeStmt: q.createKeyScopeStmt, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - deleteKeyScopeStmt: q.deleteKeyScopeStmt, - deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, - getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, - getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createAccountSecretStmt: q.createAccountSecretStmt, + createDerivedAccountStmt: q.createDerivedAccountStmt, + createImportedAccountStmt: q.createImportedAccountStmt, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, + getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, + getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, + getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, + getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAccountsByScopeStmt: q.listAccountsByScopeStmt, + listAccountsByWalletStmt: q.listAccountsByWalletStmt, + listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, + listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, + updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 121df96ffe..a7e3e5968f 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -20,6 +20,7 @@ INSERT INTO key_scopes ( ) VALUES ( $1, $2, $3, $4, $5, $6 ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id ` diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 57c00a5a20..41471588e8 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,6 +9,17 @@ import ( ) type Querier interface { + // Inserts the encrypted private key material for an account. + CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error + // Creates a new derived account under the given scope, allocating a fresh + // sequential account number from key_scopes.last_account_number. + // The allocation is atomic: the UPDATE takes the row lock on the scope row, + // returns the allocated number, and updates the counter for the next call. + CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Creates a new imported account under the given scope with NULL account + // number. Imported accounts don't follow BIP44 derivation, so they don't need + // a sequential account number. + CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -17,6 +28,16 @@ type Querier interface { DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Returns a single account by scope id and account name. + GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) + // Returns a single account by scope id and account number. + GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) + // Returns a single account by wallet id, scope tuple, and account name. + GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) + // Returns a single account by wallet id, scope tuple, and account number. + GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) + // Returns full account properties by account id. + GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) @@ -35,11 +56,27 @@ type Querier interface { InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Lists all accounts in a scope, ordered by account number. Imported accounts + // (with NULL account_number) appear last. + ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) + // Lists all accounts for a wallet, ordered by account number. Imported + // accounts (with NULL account_number) appear last. + ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) + // Lists all accounts for a wallet filtered by account name, ordered by account + // number. Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) + // Lists all accounts for a wallet and scope tuple, ordered by account number. + // Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Renames an account identified by wallet id, scope tuple, and current account name. + UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) + // Renames an account identified by wallet id, scope tuple, and account number. + UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go new file mode 100644 index 0000000000..6e4cd25831 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -0,0 +1,711 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: accounts.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const CreateAccountSecret = `-- name: CreateAccountSecret :exec +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + ?, ? +) +` + +type CreateAccountSecretParams struct { + AccountID int64 + EncryptedPrivateKey []byte +} + +// Inserts the encrypted private key material for an account. +func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error { + _, err := q.exec(ctx, q.createAccountSecretStmt, CreateAccountSecret, arg.AccountID, arg.EncryptedPrivateKey) + return err +} + +const CreateDerivedAccount = `-- name: CreateDerivedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateDerivedAccountRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Creates a new derived account under the given scope, using a caller-provided +// account number. +// +// NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation +// step with an INSERT in a single CTE. +// +// We instead: +// 1. call AllocateAccountNumber (key_scopes.sql) +// 2. call CreateDerivedAccount with the returned number +// +// Both statements run within the same SQL transaction. +func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + +const CreateImportedAccount = `-- name: CreateImportedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES (?, NULL, ?, ?, ?, ?, ?) +RETURNING id, created_at +` + +type CreateImportedAccountParams struct { + ScopeID int64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateImportedAccountRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a new imported account under the given scope with NULL account +// number. Imported accounts don't follow BIP44 derivation, so they don't need +// a sequential account number. +func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { + row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, + arg.ScopeID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateImportedAccountRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_name = ? +` + +type GetAccountByScopeAndNameParams struct { + ScopeID int64 + AccountName string +} + +type GetAccountByScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account name. +func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) + var i GetAccountByScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_number = ? +` + +type GetAccountByScopeAndNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account number. +func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) + var i GetAccountByScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_name = ? +` + +type GetAccountByWalletScopeAndNameParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type GetAccountByWalletScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account name. +func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNameStmt, GetAccountByWalletScopeAndName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + var i GetAccountByWalletScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_number = ? +` + +type GetAccountByWalletScopeAndNumberParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByWalletScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account number. +func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNumberStmt, GetAccountByWalletScopeAndNumber, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + var i GetAccountByWalletScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountPropsById = `-- name: GetAccountPropsById :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = ? +` + +type GetAccountPropsByIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 +} + +// Returns full account properties by account id. +func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByIdStmt, GetAccountPropsById, id) + var i GetAccountPropsByIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.EncryptedPublicKey, + &i.MasterFingerprint, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const ListAccountsByScope = `-- name: ListAccountsByScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts in a scope, ordered by account number. Imported accounts +// (with NULL account_number) appear last. +func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByScopeStmt, ListAccountsByScope, scopeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByScopeRow + for rows.Next() { + var i ListAccountsByScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWallet = `-- name: ListAccountsByWallet :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet, ordered by account number. Imported +// accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletStmt, ListAccountsByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletRow + for rows.Next() { + var i ListAccountsByWalletRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? AND a.account_name = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletAndNameParams struct { + WalletID int64 + AccountName string +} + +type ListAccountsByWalletAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet filtered by account name, ordered by account +// number. Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletAndNameStmt, ListAccountsByWalletAndName, arg.WalletID, arg.AccountName) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletAndNameRow + for rows.Next() { + var i ListAccountsByWalletAndNameRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +type ListAccountsByWalletScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet and scope tuple, ordered by account number. +// Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletScopeStmt, ListAccountsByWalletScope, arg.WalletID, arg.Purpose, arg.CoinType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletScopeRow + for rows.Next() { + var i ListAccountsByWalletScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows +UPDATE accounts +SET account_name = ?1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = ?2 + AND purpose = ?3 + AND coin_type = ?4 + ) + AND account_name = ?5 +` + +type UpdateAccountNameByWalletScopeAndNameParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + OldName string +} + +// Renames an account identified by wallet id, scope tuple, and current account name. +func (q *Queries) UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNameStmt, UpdateAccountNameByWalletScopeAndName, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.OldName, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateAccountNameByWalletScopeAndNumber = `-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +UPDATE accounts +SET account_name = ?1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = ?2 + AND purpose = ?3 + AND coin_type = ?4 + ) + AND account_number = ?5 +` + +type UpdateAccountNameByWalletScopeAndNumberParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +// Renames an account identified by wallet id, scope tuple, and account number. +func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNumberStmt, UpdateAccountNameByWalletScopeAndNumber, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index a0079857b9..789f34aaa0 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.allocateAccountNumberStmt, err = db.PrepareContext(ctx, AllocateAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query AllocateAccountNumber: %w", err) + } + if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) + } + if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) + } + if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -39,6 +51,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) + } + if q.getAccountByScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndNumber: %w", err) + } + if q.getAccountByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndName: %w", err) + } + if q.getAccountByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndNumber: %w", err) + } + if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -75,6 +102,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAccountsByScopeStmt, err = db.PrepareContext(ctx, ListAccountsByScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByScope: %w", err) + } + if q.listAccountsByWalletStmt, err = db.PrepareContext(ctx, ListAccountsByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWallet: %w", err) + } + if q.listAccountsByWalletAndNameStmt, err = db.PrepareContext(ctx, ListAccountsByWalletAndName); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletAndName: %w", err) + } + if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -84,6 +123,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) + } + if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -95,6 +140,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.allocateAccountNumberStmt != nil { + if cerr := q.allocateAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing allocateAccountNumberStmt: %w", cerr) + } + } + if q.createAccountSecretStmt != nil { + if cerr := q.createAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) + } + } + if q.createDerivedAccountStmt != nil { + if cerr := q.createDerivedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) + } + } + if q.createImportedAccountStmt != nil { + if cerr := q.createImportedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -120,6 +185,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.getAccountByScopeAndNameStmt != nil { + if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByScopeAndNumberStmt != nil { + if cerr := q.getAccountByScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNameStmt != nil { + if cerr := q.getAccountByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNumberStmt != nil { + if cerr := q.getAccountByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountPropsByIdStmt != nil { + if cerr := q.getAccountPropsByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -180,6 +270,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAccountsByScopeStmt != nil { + if cerr := q.listAccountsByScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByScopeStmt: %w", cerr) + } + } + if q.listAccountsByWalletStmt != nil { + if cerr := q.listAccountsByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletStmt: %w", cerr) + } + } + if q.listAccountsByWalletAndNameStmt != nil { + if cerr := q.listAccountsByWalletAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletAndNameStmt: %w", cerr) + } + } + if q.listAccountsByWalletScopeStmt != nil { + if cerr := q.listAccountsByWalletScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -195,6 +305,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.updateAccountNameByWalletScopeAndNameStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.updateAccountNameByWalletScopeAndNumberStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -242,57 +362,87 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createKeyScopeStmt *sql.Stmt - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - deleteKeyScopeStmt *sql.Stmt - deleteKeyScopeSecretsStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getKeyScopeByIDStmt *sql.Stmt - getKeyScopeByWalletAndScopeStmt *sql.Stmt - getKeyScopeSecretsStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertKeyScopeSecretsStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listKeyScopesByWalletStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + allocateAccountNumberStmt *sql.Stmt + createAccountSecretStmt *sql.Stmt + createDerivedAccountStmt *sql.Stmt + createImportedAccountStmt *sql.Stmt + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAccountByScopeAndNameStmt *sql.Stmt + getAccountByScopeAndNumberStmt *sql.Stmt + getAccountByWalletScopeAndNameStmt *sql.Stmt + getAccountByWalletScopeAndNumberStmt *sql.Stmt + getAccountPropsByIdStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAccountsByScopeStmt *sql.Stmt + listAccountsByWalletStmt *sql.Stmt + listAccountsByWalletAndNameStmt *sql.Stmt + listAccountsByWalletScopeStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateAccountNameByWalletScopeAndNameStmt *sql.Stmt + updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createKeyScopeStmt: q.createKeyScopeStmt, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - deleteKeyScopeStmt: q.deleteKeyScopeStmt, - deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, - getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, - getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + allocateAccountNumberStmt: q.allocateAccountNumberStmt, + createAccountSecretStmt: q.createAccountSecretStmt, + createDerivedAccountStmt: q.createDerivedAccountStmt, + createImportedAccountStmt: q.createImportedAccountStmt, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, + getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, + getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, + getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, + getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAccountsByScopeStmt: q.listAccountsByScopeStmt, + listAccountsByWalletStmt: q.listAccountsByWalletStmt, + listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, + listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, + updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index 241da81ea9..e464c5ce8d 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -9,6 +9,30 @@ import ( "context" ) +const AllocateAccountNumber = `-- name: AllocateAccountNumber :one +UPDATE key_scopes +SET last_account_number = last_account_number + 1 +WHERE id = ? +RETURNING id, last_account_number +` + +type AllocateAccountNumberRow struct { + ID int64 + LastAccountNumber int64 +} + +// Atomically allocates the next account number for a key scope. +// Returns the scope_id and the allocated account number. +// SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single +// CTE (unlike Postgres). So, we call this first, then pass the returned number +// to CreateAccount, within the same SQL transaction. +func (q *Queries) AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) { + row := q.queryRow(ctx, q.allocateAccountNumberStmt, AllocateAccountNumber, id) + var i AllocateAccountNumberRow + err := row.Scan(&i.ID, &i.LastAccountNumber) + return i, err +} + const CreateKeyScope = `-- name: CreateKeyScope :one INSERT INTO key_scopes ( wallet_id, @@ -20,6 +44,7 @@ INSERT INTO key_scopes ( ) VALUES ( ?, ?, ?, ?, ?, ? ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id ` diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 27e37f69b7..b05c8a8ac9 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,6 +9,30 @@ import ( ) type Querier interface { + // Atomically allocates the next account number for a key scope. + // Returns the scope_id and the allocated account number. + // SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single + // CTE (unlike Postgres). So, we call this first, then pass the returned number + // to CreateAccount, within the same SQL transaction. + AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) + // Inserts the encrypted private key material for an account. + CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error + // Creates a new derived account under the given scope, using a caller-provided + // account number. + // + // NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation + // step with an INSERT in a single CTE. + // + // We instead: + // 1) call AllocateAccountNumber (key_scopes.sql) + // 2) call CreateDerivedAccount with the returned number + // + // Both statements run within the same SQL transaction. + CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Creates a new imported account under the given scope with NULL account + // number. Imported accounts don't follow BIP44 derivation, so they don't need + // a sequential account number. + CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -17,6 +41,16 @@ type Querier interface { DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Returns a single account by scope id and account name. + GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) + // Returns a single account by scope id and account number. + GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) + // Returns a single account by wallet id, scope tuple, and account name. + GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) + // Returns a single account by wallet id, scope tuple, and account number. + GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) + // Returns full account properties by account id. + GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) @@ -35,11 +69,27 @@ type Querier interface { InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Lists all accounts in a scope, ordered by account number. Imported accounts + // (with NULL account_number) appear last. + ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) + // Lists all accounts for a wallet, ordered by account number. Imported + // accounts (with NULL account_number) appear last. + ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) + // Lists all accounts for a wallet filtered by account name, ordered by account + // number. Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) + // Lists all accounts for a wallet and scope tuple, ordered by account number. + // Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Renames an account identified by wallet id, scope tuple, and current account name. + UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) + // Renames an account identified by wallet id, scope tuple, and account number. + UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From 07382cd64d06aff398cf842a86d639aed7636825 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:04:06 -0300 Subject: [PATCH 051/695] wallet: refactor AccountStore interface to respect SRP --- wallet/internal/db/data_types.go | 54 ++++++++++++++------------------ wallet/internal/db/interface.go | 28 ++++++++--------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index c00fed1f12..120ec205c7 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -9,7 +9,6 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" - "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" ) @@ -369,8 +368,9 @@ type ScopeAddrSchema struct { InternalAddrType AddressType } -// CreateAccountParams contains the parameters for creating a new account. -type CreateAccountParams struct { +// CreateDerivedAccountParams contains the parameters for creating a new derived +// account. +type CreateDerivedAccountParams struct { // WalletID is the ID of the wallet to create the account in. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -384,9 +384,9 @@ type CreateAccountParams struct { Name string } -// ImportAccountParams contains the data required to import an account from an -// extended key. -type ImportAccountParams struct { +// CreateImportedAccountParams contains the data required to store an imported +// account from an external extended key. +type CreateImportedAccountParams struct { // WalletID is the ID of the wallet to import the account into. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -396,29 +396,22 @@ type ImportAccountParams struct { // Name is the name of the account to import. Name string - // AccountKey is the extended key for the account. - AccountKey *hdkeychain.ExtendedKey - - // MasterKeyFingerprint is the fingerprint of the master key. - MasterKeyFingerprint uint32 - // Scope is the key scope for the account. The address schema for the - // account will be determined by the default mapping for this scope. + // scope will be determined by the default mapping for this scope. Scope KeyScope -} -// ImportAccountResult holds the results of an account import operation. -type ImportAccountResult struct { - // AccountProperties contains the properties of the imported account. - AccountProperties *AccountProperties + // MasterFingerprint is the fingerprint of the master key. + MasterFingerprint uint32 - // ExternalAddrs contains the derived external addresses if the import - // was a dry run. - ExternalAddrs []AddressInfo + // EncryptedPublicKey is the encrypted extended public key for the + // account. This should be encrypted by the caller before being passed + // to the database layer. + EncryptedPublicKey []byte - // InternalAddrs contains the derived internal addresses if the import - // was a dry run. - InternalAddrs []AddressInfo + // EncryptedPrivateKey is the encrypted extended private key for the + // account. This should be encrypted by the caller before being passed + // to the database layer. A nil or empty slice indicates watch-only. + EncryptedPrivateKey []byte } // AccountProperties contains properties associated with each account, such as @@ -442,11 +435,11 @@ type AccountProperties struct { // account. ImportedKeyCount uint32 - // AccountPubKey is the account's public key that can be used to - // derive any address relevant to said account. - // - // NOTE: This may be nil for imported accounts. - AccountPubKey *hdkeychain.ExtendedKey + // EncryptedPublicKey is the encrypted account public key. This is the + // encrypted form of the extended public key that can be used to derive + // addresses for the account. The caller must decrypt this using the + // appropriate crypto key to use it. + EncryptedPublicKey []byte // MasterKeyFingerprint represents the fingerprint of the root key // corresponding to the master public key (also known as the key with @@ -521,7 +514,8 @@ type RenameAccountParams struct { Scope KeyScope // OldName is the current name of the account. This is used to identify - // the account if AccountNumber is not provided. + // the account if AccountNumber is not provided. An empty string means + // this field is not provided (use AccountNumber instead). OldName string // AccountNumber is the number of the account to rename. This is used diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index d5709a5a6f..3bec77a7c7 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -65,21 +65,21 @@ type WalletStore interface { // AccountStore defines the database actions for managing accounts. type AccountStore interface { - // CreateAccount creates a new account with the given name and scope. It - // returns the properties of the newly created account or an error if - // the - // creation fails. - CreateAccount(ctx context.Context, params CreateAccountParams) ( - *AccountInfo, error) + // CreateDerivedAccount creates a new derived account with the given name + // and scope. + // + // If the key scope does not exist, it will be automatically created with + // NULL encrypted keys using the address schema from ScopeAddrMap. + CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) - // ImportAccount imports an account from an extended key. This method - // supports normal imports, imports with a specific scope, and dry-run - // imports. The behavior is controlled by the fields in the - // ImportAccountParams struct. It returns the properties of the imported - // account and derived addresses (for dry runs) or an error if the - // import fails. The returned addresses are of type AddressInfo. - ImportAccount(ctx context.Context, params ImportAccountParams) ( - *ImportAccountResult, error) + // CreateImportedAccount stores an imported account identified by an + // extended public key. + // + // If the key scope does not exist, it will be automatically created with + // NULL encrypted keys using the address schema from ScopeAddrMap. + CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) // GetAccount retrieves information about a specific account, // identified by its name or account number within a given key scope. From 70afab2af6f80bfb04ebf60fa3420ccc2c4e0409 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 17 Jan 2026 17:27:54 -0300 Subject: [PATCH 052/695] wallet: add created at field to account info and properties --- wallet/internal/db/data_types.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 120ec205c7..1ebb8c9db7 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -351,6 +351,9 @@ type AccountInfo struct { // IsWatchOnly indicates whether the account is in watch-only mode. IsWatchOnly bool + // CreatedAt is the timestamp when the account was created in the database. + CreatedAt time.Time + // KeyScope is the key scope the account belongs to. This determines the // derivation path and the default address schema. KeyScope KeyScope @@ -454,6 +457,9 @@ type AccountProperties struct { // doesn't contain any private key information. IsWatchOnly bool + // CreatedAt is the timestamp when the account was created in the database. + CreatedAt time.Time + // AddrSchema, if non-nil, specifies an address schema override for // address generation only applicable to the account. AddrSchema *ScopeAddrSchema From 779cc883b10bed03598f9cfb0a30fc63712770ca Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 23:21:37 -0300 Subject: [PATCH 053/695] wallet: add account origin type --- wallet/internal/db/data_types.go | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 1ebb8c9db7..22a5df2ef5 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -150,6 +150,24 @@ var ( } ) +// AccountOrigin specifies the origin of an account. This is used to identify +// the account origin type, such as derived from the wallet's HD seed or +// imported from an external source. +// +// The enum values MUST match the IDs in the account_origins database table. +// See migration 000005_accounts for the canonical descriptions. +type AccountOrigin uint8 + +const ( + // DerivedAccount indicates the account was derived from a hierarchical + // deterministic key. + DerivedAccount AccountOrigin = iota + + // ImportedAccount indicates the account was imported from an external + // source. + ImportedAccount +) + // Tapscript represents a Taproot script leaf, which includes the script itself // and its corresponding control block. This is used for spending Taproot // outputs. @@ -323,12 +341,19 @@ type UpdateWalletSecretsParams struct { // AccountInfo contains all information about a single account, including its // properties and balances. type AccountInfo struct { - // AccountNumber is the unique identifier for the account. + // AccountNumber is the BIP44 account index used for derived accounts. + // Imported accounts do not follow BIP44 derivation and therefore do not + // have a meaningful account index. For those accounts, this field is + // set to 0 and must not be used when Origin is ImportedAccount. AccountNumber uint32 // AccountName is the human-readable name of the account. AccountName string + // Origin indicates whether the account was derived from the wallet's + // HD seed or imported from an external source. + Origin AccountOrigin + // ExternalKeyCount is the number of external keys that have been // derived. ExternalKeyCount uint32 @@ -420,12 +445,19 @@ type CreateImportedAccountParams struct { // AccountProperties contains properties associated with each account, such as // the account name, number, and the nubmer of derived and imported keys. type AccountProperties struct { - // AccountNumber is the internal number used to reference the account. + // AccountNumber is the BIP44 account index used for derived accounts. + // Imported accounts do not follow BIP44 derivation and therefore do not + // have a meaningful account index. For those accounts, this field is + // set to 0 and must not be used when Origin is ImportedAccount. AccountNumber uint32 // AccountName is the user-identifying name of the account. AccountName string + // Origin indicates whether the account was derived from the wallet's + // HD seed or imported from an external source. + Origin AccountOrigin + // ExternalKeyCount is the number of internal keys that have been // derived for the account. ExternalKeyCount uint32 From 535e20f1c45837e88d47fae78f82748c7fcc2de9 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:11:07 -0300 Subject: [PATCH 054/695] config: relax funlen to allow verbose names --- config/.golangci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config/.golangci.yml b/config/.golangci.yml index c457c439d3..fa008a01e4 100644 --- a/config/.golangci.yml +++ b/config/.golangci.yml @@ -62,7 +62,12 @@ linters: funlen: # Checks the number of lines in a function. # If lower than 0, disable the check. - lines: 100 + # Increased from 100 to 200 to allow more verbose parameters while still + # keeping low complexity. e.g.: + # func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, + # params CreateDerivedAccountParams) (*AccountInfo, error) { + lines: 200 + # Checks the number of statements in a function. statements: 50 From ce570a04a2714c052ad18ab02943f90dca36da52 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 31 Dec 2025 01:30:53 -0300 Subject: [PATCH 055/695] wallet: add enum range check to address types --- wallet/internal/db/address_types_common.go | 74 ++++++++++++---------- wallet/internal/db/address_types_pg.go | 23 ++++--- wallet/internal/db/address_types_sqlite.go | 23 ++++--- 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go index fa69e047b0..044f245203 100644 --- a/wallet/internal/db/address_types_common.go +++ b/wallet/internal/db/address_types_common.go @@ -7,10 +7,35 @@ import ( "fmt" ) -func addressTypeInfosFromRows[T any](rows []T, - toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { +// errInvalidAddressType is returned when an address type ID from the database +// does not fit in AddressType (uint8). In practice, this should never happen, +// but it's possible if the database is modified incorrectly or the query is +// incorrect. +var errInvalidAddressType = errors.New("invalid address type") + +// idToAddressType safely converts an integer to AddressType. It returns an +// error if the value does not correspond to a known AddressType value. +func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { + if v < 0 || v > T(Anchor) { + return 0, fmt.Errorf("%w: %d", errInvalidAddressType, v) + } + + return AddressType(v), nil +} + +// listAddressTypes is a generic helper that retrieves all address types from +// the database and converts them to AddressTypeInfo structs. +func listAddressTypes[Row any](ctx context.Context, + lister func(context.Context) ([]Row, error), + toInfo func(Row) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + rows, err := lister(ctx) + if err != nil { + return nil, fmt.Errorf("list address types: %w", err) + } types := make([]AddressTypeInfo, len(rows)) + for i, row := range rows { info, err := toInfo(row) if err != nil { @@ -23,41 +48,22 @@ func addressTypeInfosFromRows[T any](rows []T, return types, nil } -func listAddressTypes[T any](ctx context.Context, - list func(context.Context) ([]T, error), - toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { +// getAddressTypeByID is a generic helper that retrieves a single address type +// by its ID and converts it to an AddressTypeInfo struct. It returns +// ErrAddressTypeNotFound if no matching type is found. +func getAddressTypeByID[Row any, ID any](ctx context.Context, + getter func(context.Context, ID) (Row, error), queryID ID, id AddressType, + toInfo func(Row) (AddressTypeInfo, error)) (AddressTypeInfo, error) { - rows, err := list(ctx) - if err != nil { - return nil, fmt.Errorf("list address types: %w", err) - } - - return addressTypeInfosFromRows(rows, toInfo) -} - -func getAddressTypeByID[T any, ID any](ctx context.Context, - get func(context.Context, ID) (T, error), queryID ID, - id AddressType, toInfo func(T) (AddressTypeInfo, error)) ( - AddressTypeInfo, error) { - - row, err := get(ctx, queryID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return AddressTypeInfo{}, fmt.Errorf( - "address type %d: %w", id, - ErrAddressTypeNotFound, - ) - } - - return AddressTypeInfo{}, fmt.Errorf("get address type: %w", - err) + row, err := getter(ctx, queryID) + if err == nil { + return toInfo(row) } - info, err := toInfo(row) - if err != nil { - return AddressTypeInfo{}, fmt.Errorf("get address type: %w", - err) + if errors.Is(err, sql.ErrNoRows) { + return AddressTypeInfo{}, fmt.Errorf("address type %d: %w", id, + ErrAddressTypeNotFound) } - return info, nil + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", err) } diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 6f906ccc5d..242c121a6e 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -2,20 +2,20 @@ package db import ( "context" - "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - id, err := int16ToUint8(row.ID) +// pgAddressTypeRowToInfo converts a PostgreSQL address type row to an +// AddressTypeInfo struct. +func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { + addrType, err := idToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", - row.ID, err) + return AddressTypeInfo{}, err } return AddressTypeInfo{ - Type: AddressType(id), + Type: addrType, Description: row.Description, }, nil } @@ -25,8 +25,9 @@ func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes(ctx, w.queries.ListAddressTypes, - pgAddressTypeToInfo) + return listAddressTypes( + ctx, w.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ) } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -34,6 +35,8 @@ func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( func (w *PostgresWalletDB) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, - int16(id), id, pgAddressTypeToInfo) + return getAddressTypeByID( + ctx, w.queries.GetAddressTypeByID, int16(id), id, + pgAddressTypeRowToInfo, + ) } diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index b1b2d998bd..ceda868208 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -2,22 +2,22 @@ package db import ( "context" - "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, +// sqliteAddressTypeRowToInfo converts a SQLite address type row to an +// AddressTypeInfo struct. +func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, error) { - id, err := int64ToUint8(row.ID) + addrType, err := idToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", - row.ID, err) + return AddressTypeInfo{}, err } return AddressTypeInfo{ - Type: AddressType(id), + Type: addrType, Description: row.Description, }, nil } @@ -27,8 +27,9 @@ func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes(ctx, w.queries.ListAddressTypes, - sqliteAddressTypeToInfo) + return listAddressTypes( + ctx, w.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ) } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -36,6 +37,8 @@ func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, - int64(id), id, sqliteAddressTypeToInfo) + return getAddressTypeByID( + ctx, w.queries.GetAddressTypeByID, int64(id), id, + sqliteAddressTypeRowToInfo, + ) } From 5e77be2e062953edd57c8c569cacd3f07fecd51c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:27:08 -0300 Subject: [PATCH 056/695] wallet: add AccountStore implementation --- wallet/internal/db/accounts_common.go | 494 ++++++++++++++++++++++++++ wallet/internal/db/accounts_pg.go | 388 ++++++++++++++++++++ wallet/internal/db/accounts_sqlite.go | 422 ++++++++++++++++++++++ wallet/internal/db/interface.go | 35 +- 4 files changed, 1337 insertions(+), 2 deletions(-) create mode 100644 wallet/internal/db/accounts_common.go create mode 100644 wallet/internal/db/accounts_pg.go create mode 100644 wallet/internal/db/accounts_sqlite.go diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go new file mode 100644 index 0000000000..8c2681f3ad --- /dev/null +++ b/wallet/internal/db/accounts_common.go @@ -0,0 +1,494 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +var ( + // errNilDBAccountNumber is returned when the database returns a nil account + // number. In practice, this should never happen, but it's possible if the + // database is modified incorrectly or the query is incorrect. + errNilDBAccountNumber = errors.New("database returned nil account number") + + // errInvalidAccountOrigin is returned when an account origin ID from the + // database does not correspond to a known AccountOrigin value. In practice, + // this should never happen, but it's possible if the database is modified + // incorrectly or the query is incorrect. + errInvalidAccountOrigin = errors.New("invalid account origin") +) + +// validate validates required fields for creating a derived account. +func (params *CreateDerivedAccountParams) validate() error { + if params.Name == "" { + return ErrMissingAccountName + } + + return nil +} + +// validate validates required fields for creating an imported account. +func (params *CreateImportedAccountParams) validate() error { + if params.Name == "" { + return ErrMissingAccountName + } + + if len(params.EncryptedPublicKey) == 0 { + return ErrMissingAccountPublicKey + } + + return nil +} + +// isWatchOnly returns true if the account is watch-only. +func (params *CreateImportedAccountParams) isWatchOnly() bool { + return len(params.EncryptedPrivateKey) == 0 +} + +// accountPropsRow represents the raw database fields needed to construct +// AccountProperties. +type accountPropsRow[AddrTypeId, AccOriginId any] struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID AddrTypeId + ExternalTypeID AddrTypeId + IDToAddrType func(AddrTypeId) (AddressType, error) + IDToOriginType func(AccOriginId) (AccountOrigin, error) +} + +// accountPropsRowToProps converts a database row containing full account +// properties into an AccountProperties struct. The idToAddrType function is +// used to convert the internal and external address type IDs to AddressType +// values. +// +// TODO(stingelin): Add address counting support after address management is +// implemented. +func accountPropsRowToProps[AddrTypeId, AccOriginId any]( + row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { + + var accountNum uint32 + if row.AccountNumber.Valid { + var err error + + accountNum, err = int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + origin, err := row.IDToOriginType(row.OriginID) + if err != nil { + return nil, fmt.Errorf("origin: %w", err) + } + + purposeNum, err := int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("purpose: %w", err) + } + + coinTypeNum, err := int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("coin type: %w", err) + } + + internalType, err := row.IDToAddrType(row.InternalTypeID) + if err != nil { + return nil, fmt.Errorf("internal type: %w", err) + } + + externalType, err := row.IDToAddrType(row.ExternalTypeID) + if err != nil { + return nil, fmt.Errorf("external type: %w", err) + } + + var fingerprint uint32 + if row.MasterFingerprint.Valid { + fingerprint, err = int64ToUint32(row.MasterFingerprint.Int64) + if err != nil { + return nil, fmt.Errorf("master fingerprint: %w", err) + } + } + + return &AccountProperties{ + AccountNumber: accountNum, + AccountName: row.AccountName, + Origin: origin, + ExternalKeyCount: 0, + InternalKeyCount: 0, + ImportedKeyCount: 0, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterKeyFingerprint: fingerprint, + KeyScope: KeyScope{ + Purpose: purposeNum, + Coin: coinTypeNum, + }, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + AddrSchema: &ScopeAddrSchema{ + InternalAddrType: internalType, + ExternalAddrType: externalType, + }, + }, nil +} + +// accountInfosFromRows converts a slice of database rows into AccountInfo +// structs using the provided conversion function. +func accountInfosFromRows[T any](rows []T, + toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { + + accounts := make([]AccountInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + accounts[i] = *info + } + + return accounts, nil +} + +// listAccounts is a generic helper that retrieves accounts using the provided +// list function and converts the results to AccountInfo structs. +func listAccounts[T any, Args any](ctx context.Context, + lister func(context.Context, Args) ([]T, error), args Args, + toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { + + rows, err := lister(ctx, args) + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + + return accountInfosFromRows(rows, toInfo) +} + +// getAddrSchemaForScope returns the address schema for a given key scope or +// returns an error if the scope is not in ScopeAddrMap. +func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { + addrSchema, exists := ScopeAddrMap[scope] + if !exists { + return ScopeAddrSchema{}, fmt.Errorf("%w: scope %d/%d", + ErrUnknownKeyScope, scope.Purpose, scope.Coin) + } + + return addrSchema, nil +} + +// buildAccountInfo creates an AccountInfo with the provided values and zeroed +// balances and key counts while we do not yet support address counting. +// +// TODO(stingelin): Add address counting support after address management is +// implemented. +// TODO(stingelin): Add balance tracking support after transaction management is +// implemented. +func buildAccountInfo(accountNum uint32, accountName string, + origin AccountOrigin, isWatchOnly bool, createdAt time.Time, + scope KeyScope) *AccountInfo { + + return &AccountInfo{ + AccountNumber: accountNum, + AccountName: accountName, + Origin: origin, + ExternalKeyCount: 0, + InternalKeyCount: 0, + ImportedKeyCount: 0, + ConfirmedBalance: 0, + UnconfirmedBalance: 0, + IsWatchOnly: isWatchOnly, + CreatedAt: createdAt, + KeyScope: scope, + } +} + +// idToAccountOrigin safely converts an integer to AccountOrigin. It returns an +// error if the value does not correspond to a known AccountOrigin value. +func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { + if v < 0 || v > T(ImportedAccount) { + return 0, fmt.Errorf("%w: %d", errInvalidAccountOrigin, v) + } + + return AccountOrigin(v), nil +} + +// accountInfoRow represents the raw database fields needed to construct +// AccountInfo. +type accountInfoRow[AccOriginId any] struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + IDToOriginType func(AccOriginId) (AccountOrigin, error) +} + +// accountRowToInfo converts raw database field values into an AccountInfo +// struct. It handles type conversion and validation for each field. +func accountRowToInfo[AccOriginId any]( + row accountInfoRow[AccOriginId]) (*AccountInfo, error) { + + var accountNum uint32 + if row.AccountNumber.Valid { + var err error + + accountNum, err = int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + origin, err := row.IDToOriginType(row.OriginID) + if err != nil { + return nil, fmt.Errorf("origin: %w", err) + } + + purposeNum, err := int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("purpose: %w", err) + } + + coinTypeNum, err := int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("coin type: %w", err) + } + + return buildAccountInfo( + accountNum, row.AccountName, origin, row.IsWatchOnly, + row.CreatedAt, KeyScope{ + Purpose: purposeNum, + Coin: coinTypeNum, + }, + ), nil +} + +// ensureKeyScope retrieves an existing key scope or creates it if missing. It +// returns the scope ID once available. +func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( + ctx context.Context, getter func(context.Context, GetArgs) (Row, error), + getArgs GetArgs, creator func(context.Context, CreateArgs) (int64, error), + createArgs func(ScopeAddrSchema) CreateArgs, rowToID func(Row) int64, + scope KeyScope) (int64, error) { + + scopeInfo, err := getter(ctx, getArgs) + if err == nil { + // Fast path: when the scope already exists. + return rowToID(scopeInfo), nil + } + + if !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("check key scope: %w", err) + } + + defaultAddrSchema, err := getAddrSchemaForScope(scope) + if err != nil { + return 0, err + } + + // Slow path: needs to create the scope. The SQL uses + // "ON CONFLICT ... DO NOTHING RETURNING id", which means: + // - If INSERT succeeds (no conflict): returns the new row's id. + // - If INSERT conflicts (scope exists): returns NO rows, causing sqlc to + // return sql.ErrNoRows. + id, err := creator(ctx, createArgs(defaultAddrSchema)) + if err == nil { + return id, nil + } + + if !errors.Is(err, sql.ErrNoRows) { + // A real database error occurred (not a conflict). + return 0, fmt.Errorf("create key scope: %w", err) + } + + // ErrNoRows means the scope was created concurrently by another process + // (the INSERT hit DO NOTHING due to conflict). Re-fetch the scope that + // now exists. + scopeInfo, err = getter(ctx, getArgs) + if err != nil { + return 0, fmt.Errorf("get scope after create: %w", err) + } + + return rowToID(scopeInfo), nil +} + +// getAccountFunc defines a function signature for retrieving a single account. +type getAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) + +// getAccountByQuery dispatches to the appropriate query based on the provided +// account identifier. +func getAccountByQuery(ctx context.Context, query GetAccountQuery, + getByNumber getAccountFunc, getByName getAccountFunc) (*AccountInfo, + error) { + + switch { + case query.AccountNumber != nil && query.Name == nil: + return getByNumber(ctx, query) + + case query.Name != nil && query.AccountNumber == nil: + return getByName(ctx, query) + + default: + return nil, ErrInvalidAccountQuery + } +} + +// listAccountsFunc defines a function signature for listing accounts. +type listAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, + error) + +// listAccountsByQuery dispatches to the appropriate list query based on the +// provided filters. It returns an error if both scope and name filters are +// provided, as they are mutually exclusive. +func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, + listByScope listAccountsFunc, listByName listAccountsFunc, + listAll listAccountsFunc) ([]AccountInfo, error) { + + switch { + case query.Scope != nil && query.Name != nil: + return nil, ErrInvalidAccountQuery + + case query.Scope != nil: + return listByScope(ctx, query) + + case query.Name != nil: + return listByName(ctx, query) + + default: + return listAll(ctx, query) + } +} + +// renameAccountFunc defines a function signature for renaming an account. +type renameAccountFunc func(context.Context, RenameAccountParams) error + +// renameAccountByQuery dispatches to the appropriate rename query based on the +// provided account identifier (either account number or old name). +func renameAccountByQuery(ctx context.Context, params RenameAccountParams, + renameByNumber renameAccountFunc, renameByName renameAccountFunc) error { + + if params.NewName == "" { + return ErrMissingAccountName + } + + switch { + case params.AccountNumber != nil && params.OldName == "": + return renameByNumber(ctx, params) + + case params.OldName != "" && params.AccountNumber == nil: + return renameByName(ctx, params) + + default: + return ErrInvalidAccountQuery + } +} + +// getAccount is a generic helper that retrieves an account using the provided +// query function. It handles error mapping and delegates conversion to the +// toInfo function. +func getAccount[T any, Args any](ctx context.Context, + getter func(context.Context, Args) (T, error), args Args, + query GetAccountQuery, toInfo func(T) (*AccountInfo, error)) (*AccountInfo, + error) { + + row, err := getter(ctx, args) + if err == nil { + return toInfo(row) + } + + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get account: %w", err) + } + + if query.Name != nil { + return nil, fmt.Errorf("account %q in scope %d/%d: %w", *query.Name, + query.Scope.Purpose, query.Scope.Coin, ErrAccountNotFound) + } + + return nil, fmt.Errorf("account %d in scope %d/%d: %w", + *query.AccountNumber, query.Scope.Purpose, query.Scope.Coin, + ErrAccountNotFound) +} + +// renameAccount is a generic helper that updates an account name using the +// provided update function. It checks rows affected and returns an error if +// the account was not found. +func renameAccount[Args any](ctx context.Context, + update func(context.Context, Args) (int64, error), args Args, + params RenameAccountParams) error { + + rowsAffected, err := update(ctx, args) + if err != nil { + return fmt.Errorf("rename account: %w", err) + } + + if rowsAffected != 0 { + return nil + } + + if params.OldName != "" { + return fmt.Errorf("account %q in scope %d/%d: %w", params.OldName, + params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) + } + + return fmt.Errorf("account %d in scope %d/%d: %w", *params.AccountNumber, + params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) +} + +// createImportedAccount is a generic helper that creates an imported account. +// It handles ensuring the key scope exists, creating the account record, +// optionally creating the account secret for non-watch-only accounts, and +// fetching the full account properties from the database. +func createImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( + ctx context.Context, params CreateImportedAccountParams, + ensureScope func() (int64, error), + createAccount func(context.Context, CreateArgs) (CreateRow, error), + buildCreateArgs func(scopeID int64, isWatchOnly bool) CreateArgs, + rowToID func(CreateRow) int64, + createSecret func(context.Context, SecretArgs) error, + buildSecretArgs func(accountID int64) SecretArgs, + getProps func(accountID int64) (*AccountProperties, error), +) (*AccountProperties, error) { + + err := params.validate() + if err != nil { + return nil, err + } + + isWatchOnly := params.isWatchOnly() + + scopeID, err := ensureScope() + if err != nil { + return nil, err + } + + createArgs := buildCreateArgs(scopeID, isWatchOnly) + + row, err := createAccount(ctx, createArgs) + if err != nil { + return nil, fmt.Errorf("create account: %w", err) + } + + accountID := rowToID(row) + + if isWatchOnly { + return getProps(accountID) + } + + err = createSecret(ctx, buildSecretArgs(accountID)) + if err != nil { + return nil, fmt.Errorf("insert account secrets: %w", err) + } + + return getProps(accountID) +} diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go new file mode 100644 index 0000000000..fd9f3dfcf0 --- /dev/null +++ b/wallet/internal/db/accounts_pg.go @@ -0,0 +1,388 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Ensure PostgresWalletDB satisfies the AccountStore interface. +var _ AccountStore = (*PostgresWalletDB)(nil) + +// GetAccount retrieves information about a specific account, identified by its +// name or account number within a given key scope. +func (w *PostgresWalletDB) GetAccount(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + getQueries := pgAccountGetQueries{q: w.queries} + + return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) +} + +// ListAccounts returns a slice of AccountInfo for all accounts, optionally +// filtered by name or key scope. +func (w *PostgresWalletDB) ListAccounts(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + listQueries := pgAccountListQueries{q: w.queries} + + return listAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, + ) +} + +// RenameAccount changes the name of an account. The account can be identified +// by its old name or its account number. +func (w *PostgresWalletDB) RenameAccount(ctx context.Context, + params RenameAccountParams) error { + + renameQueries := pgAccountRenameQueries{q: w.queries} + + return renameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) +} + +// CreateDerivedAccount creates a new derived account with the given name and +// scope. If the key scope does not exist, it is created with NULL encrypted +// keys using the address schema provided by the caller. +func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) { + + paramsErr := params.validate() + if paramsErr != nil { + return nil, paramsErr + } + + var info *AccountInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + scopeID, err := pgEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + if err != nil { + return err + } + + row, err := qtx.CreateDerivedAccount( + ctx, sqlcpg.CreateDerivedAccountParams{ + ID: scopeID, + AccountName: params.Name, + OriginID: int16(DerivedAccount), + IsWatchOnly: false, + }, + ) + if err != nil { + return fmt.Errorf("create account: %w", err) + } + + if !row.AccountNumber.Valid { + // This should never happen unless the query is modified + // incorrectly. + return errNilDBAccountNumber + } + + accNumber, err := int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + } + + info = buildAccountInfo( + accNumber, params.Name, DerivedAccount, false, + row.CreatedAt, params.Scope, + ) + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// CreateImportedAccount stores an imported account identified by an extended +// public key. If the key scope does not exist, it is created with NULL +// encrypted keys using the address schema provided by the caller. Imported +// accounts have NULL account_number since they don't follow BIP44 derivation. +func (w *PostgresWalletDB) CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) { + + var props *AccountProperties + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + var err error + + props, err = createImportedAccount( + ctx, params, func() (int64, error) { + return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) + }, qtx.CreateImportedAccount, + pgBuildCreateImportedAccountArgs(params), + func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, + qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), + func(accountID int64) (*AccountProperties, error) { + return pgGetAccountProps(ctx, qtx, accountID) + }, + ) + + return err + }) + if err != nil { + return nil, err + } + + return props, nil +} + +// pgBuildCreateImportedAccountArgs returns a function that builds the +// CreateImportedAccountParams for PostgreSQL. +func pgBuildCreateImportedAccountArgs( + params CreateImportedAccountParams, +) func(int64, bool) sqlcpg.CreateImportedAccountParams { + + return func(scopeID int64, + isWatchOnly bool) sqlcpg.CreateImportedAccountParams { + + return sqlcpg.CreateImportedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int16(ImportedAccount), + EncryptedPublicKey: params.EncryptedPublicKey, + MasterFingerprint: sql.NullInt64{ + Int64: int64(params.MasterFingerprint), + Valid: true, + }, + IsWatchOnly: isWatchOnly, + } + } +} + +// pgBuildCreateAccountSecretArgs returns a function that builds the +// CreateAccountSecretParams for PostgreSQL. +func pgBuildCreateAccountSecretArgs( + params CreateImportedAccountParams, +) func(int64) sqlcpg.CreateAccountSecretParams { + + return func(accountID int64) sqlcpg.CreateAccountSecretParams { + return sqlcpg.CreateAccountSecretParams{ + AccountID: accountID, + EncryptedPrivateKey: params.EncryptedPrivateKey, + } + } +} + +// pgGetAccountProps fetches full account properties from the database and +// converts the row to AccountProperties. +func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, + accountID int64) (*AccountProperties, error) { + + row, err := qtx.GetAccountPropsById(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account props: %w", err) + } + + return accountPropsRowToProps(accountPropsRow[int16, int16]{ + AccountNumber: row.AccountNumber, + AccountName: row.AccountName, + OriginID: row.OriginID, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterFingerprint: row.MasterFingerprint, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + Purpose: row.Purpose, + CoinType: row.CoinType, + InternalTypeID: row.InternalTypeID, + ExternalTypeID: row.ExternalTypeID, + IDToAddrType: idToAddressType[int16], + IDToOriginType: idToAccountOrigin[int16], + }) +} + +// pgEnsureKeyScope retrieves an existing key scope or creates it if missing for +// PostgreSQL. It returns the scope ID once available. +func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, + scope KeyScope) (int64, error) { + + return ensureKeyScope( + ctx, qtx.GetKeyScopeByWalletAndScope, + sqlcpg.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, qtx.CreateKeyScope, + func(addrSchema ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { + return sqlcpg.CreateKeyScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + EncryptedCoinPubKey: nil, + InternalTypeID: int16( + addrSchema.InternalAddrType, + ), + ExternalTypeID: int16( + addrSchema.ExternalAddrType, + ), + } + }, + func(row sqlcpg.GetKeyScopeByWalletAndScopeRow) int64 { + return row.ID + }, scope, + ) +} + +// pgAccountInfoRow is a type constraint for PostgreSQL account info row types +// that share the same field structure. This enables a single generic conversion +// function to handle all account query result types. +type pgAccountInfoRow interface { + sqlcpg.GetAccountByScopeAndNameRow | + sqlcpg.GetAccountByScopeAndNumberRow | + sqlcpg.GetAccountByWalletScopeAndNameRow | + sqlcpg.GetAccountByWalletScopeAndNumberRow | + sqlcpg.ListAccountsByWalletRow | + sqlcpg.ListAccountsByWalletScopeRow | + sqlcpg.ListAccountsByWalletAndNameRow +} + +// pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo +// struct. It uses type conversion since all pgAccountInfoRow types have +// identical fields. +func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcpg.GetAccountByScopeAndNameRow(row) + + return accountRowToInfo(accountInfoRow[int16]{ + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int16], + }) +} + +// pgAccountListQueries groups PostgreSQL account listing query methods. +type pgAccountListQueries struct { + q *sqlcpg.Queries +} + +// byScope lists accounts filtered by wallet ID and key scope. +func (p pgAccountListQueries) byScope(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWalletScope, + sqlcpg.ListAccountsByWalletScopeParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + }, pgAccountRowToInfo, + ) +} + +// byName lists accounts filtered by wallet ID and account name. +func (p pgAccountListQueries) byName(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWalletAndName, + sqlcpg.ListAccountsByWalletAndNameParams{ + WalletID: int64(query.WalletID), + AccountName: *query.Name, + }, pgAccountRowToInfo, + ) +} + +// all lists all accounts for a wallet. +func (p pgAccountListQueries) all(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWallet, int64(query.WalletID), + pgAccountRowToInfo, + ) +} + +// pgAccountGetQueries groups PostgreSQL account retrieval query methods. +type pgAccountGetQueries struct { + q *sqlcpg.Queries +} + +// byNumber retrieves an account by wallet ID, scope, and account number. +func (p pgAccountGetQueries) byNumber(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, p.q.GetAccountByWalletScopeAndNumber, + sqlcpg.GetAccountByWalletScopeAndNumberParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*query.AccountNumber), + Valid: true, + }, + }, query, pgAccountRowToInfo, + ) +} + +// byName retrieves an account by wallet ID, scope, and account name. +func (p pgAccountGetQueries) byName(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, p.q.GetAccountByWalletScopeAndName, + sqlcpg.GetAccountByWalletScopeAndNameParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: *query.Name, + }, query, pgAccountRowToInfo, + ) +} + +// pgAccountRenameQueries groups PostgreSQL account rename query methods. +type pgAccountRenameQueries struct { + q *sqlcpg.Queries +} + +// byNumber renames an account identified by wallet ID, scope, and account +// number. +func (p pgAccountRenameQueries) byNumber(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, + sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*params.AccountNumber), + Valid: true, + }, + }, params, + ) +} + +// byName renames an account identified by wallet ID, scope, and old account +// name. +func (p pgAccountRenameQueries) byName(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, p.q.UpdateAccountNameByWalletScopeAndName, + sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + OldName: params.OldName, + }, params, + ) +} diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go new file mode 100644 index 0000000000..c099ac47ee --- /dev/null +++ b/wallet/internal/db/accounts_sqlite.go @@ -0,0 +1,422 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Ensure SQLiteWalletDB satisfies the AccountStore interface. +var _ AccountStore = (*SQLiteWalletDB)(nil) + +// GetAccount retrieves information about a specific account, identified by its +// name or account number within a given key scope. +func (w *SQLiteWalletDB) GetAccount(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + getQueries := sqliteAccountGetQueries{q: w.queries} + + return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) +} + +// ListAccounts returns a slice of AccountInfo for all accounts, optionally +// filtered by name or key scope. +func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + listQueries := sqliteAccountListQueries{q: w.queries} + + return listAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, + ) +} + +// RenameAccount changes the name of an account. The account can be identified +// by its old name or its account number. +func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, + params RenameAccountParams) error { + + renameQueries := sqliteAccountRenameQueries{q: w.queries} + + return renameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) +} + +// CreateDerivedAccount creates a new derived account with the given name and +// scope. If the key scope does not exist, it is created with NULL encrypted +// keys using the address schema provided by the caller. +func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) { + + paramsErr := params.validate() + if paramsErr != nil { + return nil, paramsErr + } + + var info *AccountInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + scopeID, err := sqliteEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + if err != nil { + return err + } + + row, err := sqliteAllocateAndCreateAccount( + ctx, qtx, scopeID, params.Name, + ) + if err != nil { + return fmt.Errorf("create account: %w", err) + } + + if !row.AccountNumber.Valid { + // This should never happen unless the query is modified + // incorrectly. + return errNilDBAccountNumber + } + + accNumber, err := int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + } + + info = buildAccountInfo( + accNumber, params.Name, DerivedAccount, false, + row.CreatedAt, params.Scope, + ) + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing +// for SQLite. It returns the scope ID once available. +func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, scope KeyScope) (int64, error) { + + return ensureKeyScope( + ctx, qtx.GetKeyScopeByWalletAndScope, + sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, qtx.CreateKeyScope, + func(addrSchema ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { + return sqlcsqlite.CreateKeyScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + EncryptedCoinPubKey: nil, + InternalTypeID: int64( + addrSchema.InternalAddrType, + ), + ExternalTypeID: int64( + addrSchema.ExternalAddrType, + ), + } + }, + func(row sqlcsqlite.GetKeyScopeByWalletAndScopeRow) int64 { + return row.ID + }, scope, + ) +} + +// sqliteAllocateAndCreateAccount allocates a new sequential account number and +// creates a derived account in a single atomic operation. SQLite requires a +// two-step process because it lacks PostgreSQL's UPDATE ... RETURNING clause. +func sqliteAllocateAndCreateAccount(ctx context.Context, + qtx *sqlcsqlite.Queries, scopeID int64, + accountName string) (sqlcsqlite.CreateDerivedAccountRow, error) { + + allocated, err := qtx.AllocateAccountNumber(ctx, scopeID) + if err != nil { + return sqlcsqlite.CreateDerivedAccountRow{}, + fmt.Errorf("allocate account number: %w", err) + } + + row, err := qtx.CreateDerivedAccount(ctx, + sqlcsqlite.CreateDerivedAccountParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{ + Int64: allocated.LastAccountNumber, + Valid: true, + }, + AccountName: accountName, + OriginID: int64(DerivedAccount), + IsWatchOnly: false, + }) + if err != nil { + return sqlcsqlite.CreateDerivedAccountRow{}, + fmt.Errorf("create account: %w", err) + } + + return row, nil +} + +// CreateImportedAccount stores an imported account identified by an extended +// public key. If the key scope does not exist, it is created with NULL +// encrypted keys using the address schema provided by the caller. Imported +// accounts have NULL account_number since they don't follow BIP44 derivation. +func (w *SQLiteWalletDB) CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) { + + var props *AccountProperties + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + var err error + + props, err = createImportedAccount( + ctx, params, + func() (int64, error) { + return sqliteEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + }, + qtx.CreateImportedAccount, + sqliteBuildCreateImportedAccountArgs(params), + func(row sqlcsqlite.CreateImportedAccountRow) int64 { + return row.ID + }, + qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), + func(accountID int64) (*AccountProperties, error) { + return sqliteGetAccountProps(ctx, qtx, accountID) + }, + ) + + return err + }) + if err != nil { + return nil, err + } + + return props, nil +} + +// sqliteBuildCreateImportedAccountArgs returns a function that builds the +// CreateImportedAccountParams for SQLite. +func sqliteBuildCreateImportedAccountArgs( + params CreateImportedAccountParams, +) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { + + return func(scopeID int64, + isWatchOnly bool) sqlcsqlite.CreateImportedAccountParams { + + return sqlcsqlite.CreateImportedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int64(ImportedAccount), + EncryptedPublicKey: params.EncryptedPublicKey, + MasterFingerprint: sql.NullInt64{ + Int64: int64(params.MasterFingerprint), + Valid: true, + }, + IsWatchOnly: isWatchOnly, + } + } +} + +// sqliteBuildCreateAccountSecretArgs returns a function that builds the +// CreateAccountSecretParams for SQLite. +func sqliteBuildCreateAccountSecretArgs( + params CreateImportedAccountParams, +) func(int64) sqlcsqlite.CreateAccountSecretParams { + + return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { + return sqlcsqlite.CreateAccountSecretParams{ + AccountID: accountID, + EncryptedPrivateKey: params.EncryptedPrivateKey, + } + } +} + +// sqliteGetAccountProps fetches full account properties from the database and +// converts the row to AccountProperties. +func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, + accountID int64) (*AccountProperties, error) { + + row, err := qtx.GetAccountPropsById(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account props: %w", err) + } + + return accountPropsRowToProps(accountPropsRow[int64, int64]{ + AccountNumber: row.AccountNumber, + AccountName: row.AccountName, + OriginID: row.OriginID, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterFingerprint: row.MasterFingerprint, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + Purpose: row.Purpose, + CoinType: row.CoinType, + InternalTypeID: row.InternalTypeID, + ExternalTypeID: row.ExternalTypeID, + IDToAddrType: idToAddressType[int64], + IDToOriginType: idToAccountOrigin[int64], + }) +} + +// sqliteAccountInfoRow is a type constraint for SQLite account info row types +// that share the same field structure. This enables a single generic conversion +// function to handle all account query result types. +type sqliteAccountInfoRow interface { + sqlcsqlite.GetAccountByScopeAndNameRow | + sqlcsqlite.GetAccountByScopeAndNumberRow | + sqlcsqlite.GetAccountByWalletScopeAndNameRow | + sqlcsqlite.GetAccountByWalletScopeAndNumberRow | + sqlcsqlite.ListAccountsByWalletRow | + sqlcsqlite.ListAccountsByWalletScopeRow | + sqlcsqlite.ListAccountsByWalletAndNameRow +} + +// sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo +// struct. It uses type conversion since all sqliteAccountInfoRow types have +// identical fields. +func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, + error) { + + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcsqlite.GetAccountByScopeAndNameRow(row) + + return accountRowToInfo(accountInfoRow[int64]{ + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int64], + }) +} + +// sqliteAccountListQueries groups SQLite account listing query methods. +type sqliteAccountListQueries struct { + q *sqlcsqlite.Queries +} + +// byScope lists accounts filtered by wallet ID and key scope. +func (s sqliteAccountListQueries) byScope(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWalletScope, + sqlcsqlite.ListAccountsByWalletScopeParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + }, sqliteAccountRowToInfo, + ) +} + +// byName lists accounts filtered by wallet ID and account name. +func (s sqliteAccountListQueries) byName(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWalletAndName, + sqlcsqlite.ListAccountsByWalletAndNameParams{ + WalletID: int64(query.WalletID), + AccountName: *query.Name, + }, sqliteAccountRowToInfo, + ) +} + +// all lists all accounts for a wallet. +func (s sqliteAccountListQueries) all(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWallet, int64(query.WalletID), + sqliteAccountRowToInfo, + ) +} + +// sqliteAccountGetQueries groups SQLite account retrieval query methods. +type sqliteAccountGetQueries struct { + q *sqlcsqlite.Queries +} + +// byNumber retrieves an account by wallet ID, scope, and account number. +func (s sqliteAccountGetQueries) byNumber(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, s.q.GetAccountByWalletScopeAndNumber, + sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*query.AccountNumber), + Valid: true, + }, + }, query, sqliteAccountRowToInfo, + ) +} + +// byName retrieves an account by wallet ID, scope, and account name. +func (s sqliteAccountGetQueries) byName(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount(ctx, s.q.GetAccountByWalletScopeAndName, + sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: *query.Name, + }, query, sqliteAccountRowToInfo, + ) +} + +// sqliteAccountRenameQueries groups SQLite account rename query methods. +type sqliteAccountRenameQueries struct { + q *sqlcsqlite.Queries +} + +// byNumber renames an account identified by wallet ID, scope, and account +// number. +func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, + sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*params.AccountNumber), + Valid: true, + }, + }, params, + ) +} + +// byName renames an account identified by wallet ID, scope, and old account +// name. +func (s sqliteAccountRenameQueries) byName(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, s.q.UpdateAccountNameByWalletScopeAndName, + sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + OldName: params.OldName, + }, params, + ) +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 3bec77a7c7..9019fc2ad6 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -23,9 +23,40 @@ var ( // ErrNilDB is returned when a nil database connection pointer is // provided to the wallet. - ErrNilDB = errors.New( - "wallet requires a non-nil database connection", + ErrNilDB = errors.New("wallet requires a non-nil database connection") + + // ErrAccountNotFound is returned when an account is not found in the + // database. + ErrAccountNotFound = errors.New("account not found") + + // ErrKeyScopeNotFound is returned when a key scope is not found in the + // database. + ErrKeyScopeNotFound = errors.New("key scope not found") + + // ErrUnknownKeyScope is returned when a key scope is not found in + // ScopeAddrMap. + ErrUnknownKeyScope = errors.New("unknown scope in ScopeAddrMap") + + // ErrInvalidAccountQuery is returned when both or neither account filters + // are provided in GetAccount or RenameAccount. + ErrInvalidAccountQuery = errors.New( + "exactly one of Name or AccountNumber must be provided", + ) + + // ErrMissingAccountPublicKey is returned when an imported account is + // missing the encrypted public key. + ErrMissingAccountPublicKey = errors.New( + "imported account requires an encrypted public key", ) + + // ErrMissingAccountName is returned when an account is being created + // without a name. + ErrMissingAccountName = errors.New("account name is required") + + // ErrMaxAccountNumberReached indicates that no more accounts can be created + // within a key scope because the account number counter has reached its + // maximum representable value. + ErrMaxAccountNumberReached = errors.New("max account number reached") ) // WalletStore defines the methods for wallet-level operations. From 95dd308515ad775762b0290ef30aab9c90b7a237 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:45:05 -0300 Subject: [PATCH 057/695] wallet: refactor NewTestStore to return Queries instead of raw DB --- .../internal/db/itest/fixtures_common_test.go | 265 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 29 ++ .../internal/db/itest/fixtures_sqlite_test.go | 29 ++ wallet/internal/db/itest/fixtures_test.go | 100 ------- wallet/internal/db/itest/pg_test.go | 7 +- wallet/internal/db/itest/sqlite_test.go | 7 +- wallet/internal/db/itest/wallet_store_test.go | 8 +- 7 files changed, 337 insertions(+), 108 deletions(-) create mode 100644 wallet/internal/db/itest/fixtures_common_test.go create mode 100644 wallet/internal/db/itest/fixtures_pg_test.go create mode 100644 wallet/internal/db/itest/fixtures_sqlite_test.go delete mode 100644 wallet/internal/db/itest/fixtures_test.go diff --git a/wallet/internal/db/itest/fixtures_common_test.go b/wallet/internal/db/itest/fixtures_common_test.go new file mode 100644 index 0000000000..9ad3a2ee97 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_common_test.go @@ -0,0 +1,265 @@ +package itest + +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" +) + +// CreateWalletParamsFixture creates test parameters for wallet creation. +func CreateWalletParamsFixture(name string) db.CreateWalletParams { + return db.CreateWalletParams{ + Name: name, + IsImported: false, + ManagerVersion: 1, + IsWatchOnly: false, + EncryptedMasterPrivKey: RandomBytes(32), + EncryptedMasterPubKey: RandomBytes(32), + MasterKeyPubParams: RandomBytes(16), + MasterKeyPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoPubKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + } +} + +// CreateImportedWalletParams creates test parameters for an imported wallet. +func CreateImportedWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsImported = true + + return params +} + +// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. +func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsWatchOnly = true + params.EncryptedMasterPrivKey = nil + params.MasterKeyPrivParams = nil + params.EncryptedCryptoPrivKey = nil + params.EncryptedCryptoScriptKey = nil + + return params +} + +// RandomBytes generates random bytes for test data. +func RandomBytes(n int) []byte { + b := make([]byte, n) + + _, err := rand.Read(b) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random bytes: %v", err)) + } + + return b +} + +// RandomHash generates a random chainhash.Hash for testing. +func RandomHash() chainhash.Hash { + var h chainhash.Hash + + _, err := rand.Read(h[:]) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random hash: %v", err)) + } + + return h +} + +// NewBlockFixture creates a new Block with the provided height, current time, +// and random hash. +func NewBlockFixture(height uint32) db.Block { + hash := RandomHash() + timestamp := time.Now().UTC() + + return db.Block{ + Hash: hash, + Height: height, + Timestamp: timestamp, + } +} + +// AccountTestCase defines a reusable account fixture for tests. It provides +// a unified way to describe both derived and imported accounts across all +// standard key scopes. +type AccountTestCase struct { + // Name is the account name to use in tests. + Name string + + // Scope is the key scope for the account. + Scope db.KeyScope + + // Origin indicates whether the account is derived or imported. + Origin db.AccountOrigin + + // IsWatchOnly indicates whether the account has no private key. + // Only relevant for imported accounts. + IsWatchOnly bool +} + +// DerivedAccountCases contains derived account fixtures across all standard +// key scopes, with multiple accounts per scope. +var DerivedAccountCases = []AccountTestCase{ + { + Name: "derived-bip84-default", + Scope: db.KeyScopeBIP0084, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip84-savings", + Scope: db.KeyScopeBIP0084, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip86-default", + Scope: db.KeyScopeBIP0086, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip86-savings", + Scope: db.KeyScopeBIP0086, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip44-default", + Scope: db.KeyScopeBIP0044, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip44-savings", + Scope: db.KeyScopeBIP0044, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip49-default", + Scope: db.KeyScopeBIP0049Plus, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip49-savings", + Scope: db.KeyScopeBIP0049Plus, + Origin: db.DerivedAccount, + }, +} + +// ImportedAccountCases contains imported account fixtures (with private keys) +// across multiple key scopes. +var ImportedAccountCases = []AccountTestCase{ + { + Name: "imported-bip84-main", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip84-hardware", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip86-main", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip86-hardware", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip44-legacy", + Scope: db.KeyScopeBIP0044, + Origin: db.ImportedAccount, + }, +} + +// WatchOnlyAccountCases contains watch-only imported account fixtures (no +// private keys) across multiple key scopes. +var WatchOnlyAccountCases = []AccountTestCase{ + { + Name: "watchonly-bip84-cold", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip84-monitor", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip86-cold", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip86-monitor", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, +} + +// AllAccountCases combines all account test cases (derived, imported, and +// watch-only) into a single slice. +var AllAccountCases = append( + append(DerivedAccountCases, ImportedAccountCases...), + WatchOnlyAccountCases..., +) + +// AllImportedAccountCases combines imported and watch-only account cases. +var AllImportedAccountCases = append( + ImportedAccountCases, WatchOnlyAccountCases..., +) + +// DerivedParams converts the test case to CreateDerivedAccountParams. +func (tc AccountTestCase) DerivedParams( + walletID uint32) db.CreateDerivedAccountParams { + + return db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: tc.Scope, + Name: tc.Name, + } +} + +// ImportedParams converts the test case to CreateImportedAccountParams. If +// IsWatchOnly is true, EncryptedPrivateKey will be nil. +func (tc AccountTestCase) ImportedParams( + walletID uint32) db.CreateImportedAccountParams { + + params := db.CreateImportedAccountParams{ + WalletID: walletID, + Name: tc.Name, + Scope: tc.Scope, + MasterFingerprint: 12345, + EncryptedPublicKey: RandomBytes(32), + } + + if !tc.IsWatchOnly { + params.EncryptedPrivateKey = RandomBytes(32) + } + + return params +} + +// FilterAccountsByScope returns a slice of AccountTestCases filtered by the +// given key scope. +func FilterAccountsByScope(scope db.KeyScope) []AccountTestCase { + var filtered []AccountTestCase + + for _, tc := range AllAccountCases { + if tc.Scope == scope { + filtered = append(filtered, tc) + } + } + + return filtered +} diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go new file mode 100644 index 0000000000..02c54504c3 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -0,0 +1,29 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + "github.com/stretchr/testify/require" +) + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, + height uint32) db.Block { + t.Helper() + + block := NewBlockFixture(height) + err := queries.InsertBlock( + t.Context(), sqlcpg.InsertBlockParams{ + BlockHeight: int32(block.Height), + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + }, + ) + require.NoError(t, err, "failed to insert block") + + return block +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go new file mode 100644 index 0000000000..3881ad6328 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -0,0 +1,29 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, + height uint32) db.Block { + t.Helper() + + block := NewBlockFixture(height) + err := queries.InsertBlock( + t.Context(), sqlcsqlite.InsertBlockParams{ + BlockHeight: int64(block.Height), + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + }, + ) + require.NoError(t, err, "failed to insert block") + + return block +} diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go deleted file mode 100644 index 78fc89aa57..0000000000 --- a/wallet/internal/db/itest/fixtures_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package itest - -import ( - "crypto/rand" - "database/sql" - "fmt" - "testing" - "time" - - "github.com/btcsuite/btcd/chainhash/v2" - "github.com/btcsuite/btcwallet/wallet/internal/db" - "github.com/stretchr/testify/require" -) - -// CreateWalletParamsFixture creates test parameters for wallet creation. -func CreateWalletParamsFixture(name string) db.CreateWalletParams { - return db.CreateWalletParams{ - Name: name, - IsImported: false, - ManagerVersion: 1, - IsWatchOnly: false, - EncryptedMasterPrivKey: RandomBytes(32), - EncryptedMasterPubKey: RandomBytes(32), - MasterKeyPubParams: RandomBytes(16), - MasterKeyPrivParams: RandomBytes(16), - EncryptedCryptoPrivKey: RandomBytes(32), - EncryptedCryptoPubKey: RandomBytes(32), - EncryptedCryptoScriptKey: RandomBytes(32), - } -} - -// CreateImportedWalletParams creates test parameters for an imported wallet. -func CreateImportedWalletParams(name string) db.CreateWalletParams { - params := CreateWalletParamsFixture(name) - params.IsImported = true - - return params -} - -// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. -func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { - params := CreateWalletParamsFixture(name) - params.IsWatchOnly = true - params.EncryptedMasterPrivKey = nil - params.MasterKeyPrivParams = nil - params.EncryptedCryptoPrivKey = nil - params.EncryptedCryptoScriptKey = nil - - return params -} - -// CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { - t.Helper() - - hash := RandomHash() - timestamp := time.Now().UTC() - - // TODO(gustavostingelin): use the block store to insert the block when - // available. - query := ` - INSERT INTO blocks (block_height, header_hash, block_timestamp) - VALUES ($1, $2, $3) - ` - _, err := dbConn.ExecContext(t.Context(), query, - height, hash[:], timestamp.Unix()) - require.NoError(t, err, "failed to insert block") - - return db.Block{ - Hash: hash, - Height: height, - Timestamp: timestamp, - } -} - -// RandomBytes generates random bytes for test data. -func RandomBytes(n int) []byte { - b := make([]byte, n) - - _, err := rand.Read(b) - if err != nil { - // This should never happen. - panic(fmt.Sprintf("failed to generate random bytes: %v", err)) - } - - return b -} - -// RandomHash generates a random chainhash.Hash for testing. -func RandomHash() chainhash.Hash { - var h chainhash.Hash - - _, err := rand.Read(h[:]) - if err != nil { - // This should never happen. - panic(fmt.Sprintf("failed to generate random hash: %v", err)) - } - - return h -} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 1d6f6f2710..e93c96a87d 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -181,7 +182,7 @@ func NewPostgresDB(t *testing.T) *sql.DB { // NewTestStore creates a PostgreSQL wallet store and returns it along with the // underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { t.Helper() dbConn := NewPostgresDB(t) @@ -189,5 +190,7 @@ func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { store, err := db.NewPostgresWalletDB(dbConn) require.NoError(t, err, "failed to create wallet store") - return store, dbConn + queries := sqlcpg.New(dbConn) + + return store, queries } diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 30b6c648a2..4f29aff1c7 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) @@ -40,7 +41,7 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // NewTestStore creates the SQLite wallet store and returns it along with the // underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { t.Helper() dbConn := NewSQLiteDB(t) @@ -48,5 +49,7 @@ func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { store, err := db.NewSQLiteWalletDB(dbConn) require.NoError(t, err, "failed to create wallet store") - return store, dbConn + queries := sqlcsqlite.New(dbConn) + + return store, queries } diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index a311fc2997..5408b66bc5 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -181,13 +181,13 @@ func TestListWallets(t *testing.T) { func TestUpdateWallet_SyncedTo(t *testing.T) { t.Parallel() - store, dbConn := NewTestStore(t) + store, queries := NewTestStore(t) params := CreateWalletParamsFixture("update-sync-wallet") created, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) - block := CreateBlockFixture(t, dbConn, 100) + block := CreateBlockFixture(t, queries, 100) updateParams := db.UpdateWalletParams{ WalletID: created.ID, @@ -215,7 +215,7 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { func TestUpdateWallet_BirthdayBlock(t *testing.T) { t.Parallel() - store, dbConn := NewTestStore(t) + store, queries := NewTestStore(t) params := CreateWalletParamsFixture("update-birthday-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -224,7 +224,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { // Initially, BirthdayBlock should be nil. require.Nil(t, created.BirthdayBlock) - block := CreateBlockFixture(t, dbConn, 50) + block := CreateBlockFixture(t, queries, 50) updateParams := db.UpdateWalletParams{ WalletID: created.ID, From 7f2994315bfa91ceec69fce8e2c1bba6df0b95b0 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:45:22 -0300 Subject: [PATCH 058/695] wallet: add AccountStore tests --- .../internal/db/itest/account_store_test.go | 804 ++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 11 + 2 files changed, 815 insertions(+) create mode 100644 wallet/internal/db/itest/account_store_test.go diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go new file mode 100644 index 0000000000..c16263716c --- /dev/null +++ b/wallet/internal/db/itest/account_store_test.go @@ -0,0 +1,804 @@ +//go:build itest + +package itest + +import ( + "context" + "slices" + "sort" + "strconv" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateAccounts verifies that Create**Account correctly creates accounts +// across all standard key scopes between multiple wallets. +func TestCreateAccounts(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + // Create 3 wallets to ensure wallet_id scoping works. + for i := range 3 { + walletID := newWallet(t, store, "wallet-"+strconv.Itoa(i)) + + for _, tc := range DerivedAccountCases { + params := tc.DerivedParams(walletID) + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + } + + for _, tc := range ImportedAccountCases { + params := tc.ImportedParams(walletID) + props, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, props) + requireAccountPropertiesMatches(t, props, tc) + require.NotEmpty(t, props.EncryptedPublicKey) + } + } +} + +// TestCreateDerivedAccountErrors verifies that CreateDerivedAccount returns +// appropriate errors for invalid inputs. +func TestCreateDerivedAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-create-derived-account-errors") + + tests := []struct { + name string + params db.CreateDerivedAccountParams + wantErr error + }{ + { + name: "missing name", + params: db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "", + }, + wantErr: db.ErrMissingAccountName, + }, + { + name: "unknown scope", + params: db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + Name: "unknown-scope-account", + }, + wantErr: db.ErrUnknownKeyScope, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + info, err := store.CreateDerivedAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, info) + }) + } +} + +// TestCreateDerivedAccountDuplicateName verifies that creating a derived +// account with a duplicate name in the same scope fails. +func TestCreateDerivedAccountDuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "duplicate-name-wallet") + + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "duplicate-account", + } + + _, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second account with same name in same scope. + _, err = store.CreateDerivedAccount(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateDerivedAccountSameNameDifferentScopes verifies that accounts with +// the same name can exist in different scopes. +func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "multi-scope-wallet") + + accountName := "shared-name" + scopes := []db.KeyScope{ + db.KeyScopeBIP0084, + db.KeyScopeBIP0086, + db.KeyScopeBIP0044, + db.KeyScopeBIP0049Plus, + } + + for _, scope := range scopes { + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: accountName, + } + + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.Equal(t, accountName, info.AccountName) + require.Equal(t, scope, info.KeyScope) + } +} + +// TestCreateDerivedAccountSequentialNumbers verifies that derived accounts +// within the same scope receive sequential account numbers starting from 0. +func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "sequential-wallet") + + scope := db.KeyScopeBIP0084 + const numAccounts = 5 + + for i := range numAccounts { + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: "account-" + strconv.Itoa(i), + } + + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.Equal(t, uint32(i), info.AccountNumber, + "account %d should have number %d", i, i) + } +} + +// TestCreateDerivedAccountConcurrent verifies that concurrent account creation +// yields unique, sequential account numbers without errors. +func TestCreateDerivedAccountConcurrent(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "concurrent-wallet") + + scope := db.KeyScopeBIP0084 + + const workers = 20 + results := make([]uint32, workers) + var wg sync.WaitGroup + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + for i := range workers { + wg.Add(1) + go func(i int) { + defer wg.Done() + info, err := store.CreateDerivedAccount( + ctx, db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: "acct-concurrent-" + strconv.Itoa(i), + }, + ) + require.NoError(t, err) + results[i] = info.AccountNumber + }(i) + } + + wg.Wait() + + // Verify all numbers are unique and sequential. + sort.Slice(results, func(i, j int) bool { + return results[i] < results[j] + }) + for i := range workers { + require.Equal(t, uint32(i), results[i]) + } +} + +// TestCreateImportedAccountErrors verifies that CreateImportedAccount returns +// appropriate errors for invalid inputs. +func TestCreateImportedAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-create-imported-account-errors") + + tests := []struct { + name string + params db.CreateImportedAccountParams + wantErr error + }{ + { + name: "missing name", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: RandomBytes(32), + }, + wantErr: db.ErrMissingAccountName, + }, + { + name: "missing public key", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "missing-pubkey", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: nil, + }, + wantErr: db.ErrMissingAccountPublicKey, + }, + { + name: "unknown scope", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "unknown-scope", + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + EncryptedPublicKey: RandomBytes(32), + }, + wantErr: db.ErrUnknownKeyScope, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + props, err := store.CreateImportedAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, props) + }) + } +} + +// TestCreateImportedAccountDuplicateName verifies that creating an imported +// account with a duplicate name in the same scope fails. +func TestCreateImportedAccountDuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "imported-duplicate-name-wallet") + + params := db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "duplicate-imported", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: RandomBytes(32), + } + + _, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second imported account with same name in same + // scope. + params.EncryptedPublicKey = RandomBytes(32) + _, err = store.CreateImportedAccount(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestGetAccount verifies that GetAccount correctly retrieves accounts +// by name or account number. +func TestGetAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-get-account") + + createAllAccounts(t, store, walletID) + + for _, tc := range AllAccountCases { + accNumber := uint32(0) + + t.Run("by name-"+tc.Name, func(t *testing.T) { + query := getAccountQueryByName(walletID, tc.Scope, tc.Name) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + accNumber = info.AccountNumber + }) + + if tc.Origin == db.ImportedAccount { + continue + } + + t.Run(fmt.Sprintf("by number-%d-%s", accNumber, tc.Name), + func(t *testing.T) { + + query := getAccountQueryByNumber(walletID, tc.Scope, accNumber) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + }) + } +} + +// TestGetAccountNotFound verifies that GetAccount returns ErrAccountNotFound +// when querying a non-existent account. +func TestGetAccountNotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-get-account-not-found") + + createAllAccounts(t, store, walletID) + + scope := db.KeyScopeBIP0084 + + t.Run("by name", func(t *testing.T) { + t.Parallel() + + query := getAccountQueryByName(walletID, scope, "non-existent") + info, err := store.GetAccount(t.Context(), query) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) + }) + + t.Run("by number", func(t *testing.T) { + t.Parallel() + + query := getAccountQueryByNumber(walletID, scope, 99999) + info, err := store.GetAccount(t.Context(), query) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) + }) +} + +// TestListAccounts verifies that ListAccounts returns accounts for a +// wallet with various filters. +func TestListAccounts(t *testing.T) { + t.Parallel() + + // Ensure that has at least 3 accounts to be tested. + require.GreaterOrEqual(t, len(AllAccountCases), 3) + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-list-accounts") + + createAllAccounts(t, store, walletID) + + t.Run("all accounts", func(t *testing.T) { + t.Parallel() + + query := db.ListAccountsQuery{WalletID: walletID} + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, len(AllAccountCases)) + for _, tc := range AllAccountCases { + acc := findAccountInList(t, accounts, tc) + requireAccountMatches(t, &acc, tc) + } + }) + + t.Run("filter by scope", func(t *testing.T) { + t.Parallel() + + scope := db.KeyScopeBIP0084 + query := db.ListAccountsQuery{ + WalletID: walletID, + Scope: &scope, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + + cases := FilterAccountsByScope(scope) + + require.Len(t, accounts, len(cases)) + for _, tc := range cases { + acc := findAccountInList(t, accounts, tc) + requireAccountMatches(t, &acc, tc) + } + }) + + t.Run("filter by name", func(t *testing.T) { + t.Parallel() + + // Ensure that has at least 3 derived accounts to be tested. + require.GreaterOrEqual(t, len(DerivedAccountCases), 3) + + // Pick an acc that exists in our fixtures. + tc := DerivedAccountCases[1] + query := db.ListAccountsQuery{ + WalletID: walletID, + Name: &tc.Name, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, 1) + requireAccountMatches(t, &accounts[0], tc) + }) + + t.Run("empty result", func(t *testing.T) { + t.Parallel() + + // Create a new wallet with no accounts. + emptyWalletID := newWallet(t, store, "wallet-list-empty") + query := db.ListAccountsQuery{WalletID: emptyWalletID} + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Empty(t, accounts) + }) +} + +// TestListAccountsOrdering verifies that ListAccounts returns derived accounts +// ordered by account number, with imported accounts (NULL account_number) last. +func TestListAccountsOrdering(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-list-ordering") + + scope := db.KeyScopeBIP0084 + + // Create accounts in mixed order: imported, derived, imported, derived. + createImportedAccount(t, store, walletID, scope, "imported-first") + createDerivedAccount(t, store, walletID, scope, "derived-0") + createImportedAccount(t, store, walletID, scope, "imported-second") + createDerivedAccount(t, store, walletID, scope, "derived-1") + + query := db.ListAccountsQuery{ + WalletID: walletID, + Scope: &scope, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, 4) + + // Derived accounts should come first (ordered by account number). + require.Equal(t, "derived-0", accounts[0].AccountName) + require.Equal(t, db.DerivedAccount, accounts[0].Origin) + require.Equal(t, uint32(0), accounts[0].AccountNumber) + + require.Equal(t, "derived-1", accounts[1].AccountName) + require.Equal(t, db.DerivedAccount, accounts[1].Origin) + require.Equal(t, uint32(1), accounts[1].AccountNumber) + + // Imported accounts should come last. + require.Equal(t, db.ImportedAccount, accounts[2].Origin) + require.Equal(t, db.ImportedAccount, accounts[3].Origin) +} + +// TestAccountCreatedAtTimestamp verifies that accounts have their CreatedAt +// field properly set and that it reflects the order of account creation. +func TestAccountCreatedAtTimestamp(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-created-at") + + scope := db.KeyScopeBIP0084 + + // Create three accounts with slight delays to ensure different + // timestamps. + var accounts []db.AccountInfo + for i := range 3 { + time.Sleep(1 * time.Second) + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: fmt.Sprintf("account-%d", i), + } + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + accounts = append(accounts, *info) + } + + // Verify all accounts have CreatedAt populated. + for i, acc := range accounts { + require.False(t, acc.CreatedAt.IsZero(), + "account %d should have CreatedAt set", i) + require.WithinDuration(t, time.Now(), acc.CreatedAt, 5*time.Second, + "account %d CreatedAt should be recent", i) + } + + // Verify accounts are ordered by creation time. + require.True(t, accounts[0].CreatedAt.Before(accounts[1].CreatedAt), + "account 0 should have CreatedAt before account 1") + require.True(t, accounts[1].CreatedAt.Before(accounts[2].CreatedAt), + "account 1 should have CreatedAt before account 2") +} + +// TestRenameAccount verifies that RenameAccount successfully renames accounts +// by name and by account number. +func TestRenameAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-rename-account") + + scope := db.KeyScopeBIP0084 + + // Create two accounts to rename. First account should get number 0. + createDerivedAccount(t, store, walletID, scope, "original-name-1") + createDerivedAccount(t, store, walletID, scope, "original-name-2") + + t.Run("rename by name", func(t *testing.T) { + oldName := "original-name-1" + newName := "renamed-by-name" + + err := store.RenameAccount(t.Context(), db.RenameAccountParams{ + WalletID: walletID, + Scope: scope, + OldName: oldName, + NewName: newName, + }) + require.NoError(t, err) + + // Verify the rename worked. + query := getAccountQueryByName(walletID, scope, newName) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.Equal(t, newName, info.AccountName) + require.Equal(t, uint32(0), info.AccountNumber) + + // Verify the old name no longer exists. + oldQuery := getAccountQueryByName(walletID, scope, oldName) + _, err = store.GetAccount(t.Context(), oldQuery) + require.ErrorIs(t, err, db.ErrAccountNotFound) + }) + + t.Run("rename by number", func(t *testing.T) { + // First derived account has number 0. + accNum := uint32(0) + newName := "renamed-by-number" + + err := store.RenameAccount(t.Context(), db.RenameAccountParams{ + WalletID: walletID, + Scope: scope, + AccountNumber: &accNum, + NewName: newName, + }) + require.NoError(t, err) + + // Verify the rename worked. + query := getAccountQueryByNumber(walletID, scope, accNum) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.Equal(t, newName, info.AccountName) + }) +} + +// TestRenameAccountErrors verifies that RenameAccount returns appropriate +// errors for invalid inputs and missing accounts. +func TestRenameAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-rename-account-errors") + + createAllAccounts(t, store, walletID) + + nonExistentName := "nonexistent" + nonExistentNum := uint32(99999) + + tests := []struct { + name string + params db.RenameAccountParams + wantErr error + }{ + { + name: "not found", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + NewName: "new-name", + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "invalid - both set", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + AccountNumber: &nonExistentNum, + NewName: "new-name", + }, + wantErr: db.ErrInvalidAccountQuery, + }, + { + name: "invalid - neither set", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + NewName: "new-name", + }, + wantErr: db.ErrInvalidAccountQuery, + }, + { + name: "invalid - empty new name", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + NewName: "", + }, + wantErr: db.ErrMissingAccountName, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := store.RenameAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +func newWallet(t *testing.T, store db.WalletStore, name string) uint32 { + t.Helper() + + walletParams := CreateWalletParamsFixture(name) + walletInfo, err := store.CreateWallet(t.Context(), walletParams) + require.NoError(t, err) + + return walletInfo.ID +} + +// createAllAccounts creates all accounts from AllAccountCases for the given +// wallet ID using the provided account store. +func createAllAccounts(t *testing.T, store db.AccountStore, walletID uint32) { + t.Helper() + + for _, tc := range AllAccountCases { + switch tc.Origin { + case db.DerivedAccount: + params := tc.DerivedParams(walletID) + _, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + + case db.ImportedAccount: + params := tc.ImportedParams(walletID) + _, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + } + } +} + +// getAccountQueryByName creates a GetAccountQuery for looking up an account +// by name within a specific wallet and scope. +func getAccountQueryByName(walletID uint32, scope db.KeyScope, + name string) db.GetAccountQuery { + + return db.GetAccountQuery{ + WalletID: walletID, + Scope: scope, + Name: &name, + } +} + +// getAccountQueryByNumber creates a GetAccountQuery for looking up an +// account by account number within a specific wallet and scope. +func getAccountQueryByNumber(walletID uint32, scope db.KeyScope, + num uint32) db.GetAccountQuery { + + return db.GetAccountQuery{ + WalletID: walletID, + Scope: scope, + AccountNumber: &num, + } +} + +// createDerivedAccount creates a new derived account with the given name, +// scope, and wallet ID using the provided account store. +func createDerivedAccount(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, name string) { + + t.Helper() + + _, err := store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: name, + }, + ) + require.NoError(t, err) +} + +// createImportedAccount creates a new imported account with the given name, +// scope, and wallet ID using the provided account store. A random public key +// is generated for the account. +func createImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, name string) { + + t.Helper() + + _, err := store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: walletID, + Name: name, + Scope: scope, + EncryptedPublicKey: RandomBytes(32), + }, + ) + require.NoError(t, err) +} + +// requireAccountMatches asserts that the provided AccountInfo matches the +// expected AccountTestCase, including name, scope, origin, watch-only status, +// and creation timestamp. +func requireAccountMatches(t *testing.T, info *db.AccountInfo, + tc AccountTestCase) { + + t.Helper() + + require.Equal(t, tc.Name, info.AccountName) + require.Equal(t, tc.Scope, info.KeyScope) + require.Equal(t, tc.Origin, info.Origin) + require.Equal(t, tc.IsWatchOnly, info.IsWatchOnly) + + // Verify CreatedAt is populated and recent. + require.False(t, info.CreatedAt.IsZero(), "CreatedAt should be set") + require.WithinDuration(t, time.Now(), info.CreatedAt, 5*time.Second, + "CreatedAt should be recent") +} + +// requireAccountPropertiesMatches asserts that the provided AccountProperties +// matches the expected AccountTestCase, including name, scope, origin, +// watch-only status, and creation timestamp. +func requireAccountPropertiesMatches(t *testing.T, props *db.AccountProperties, + tc AccountTestCase) { + + t.Helper() + + require.Equal(t, tc.Name, props.AccountName) + require.Equal(t, tc.Scope, props.KeyScope) + require.Equal(t, tc.Origin, props.Origin) + require.Equal(t, tc.IsWatchOnly, props.IsWatchOnly) + + // Verify CreatedAt is populated and recent. + require.False(t, props.CreatedAt.IsZero(), "CreatedAt should be set") + require.WithinDuration(t, time.Now(), props.CreatedAt, 5*time.Second, + "CreatedAt should be recent") +} + +// findAccountInList searches for an account in the provided list that matches +// the expected AccountTestCase by name and scope. It fails the test if the +// account is not found. +func findAccountInList(t *testing.T, accounts []db.AccountInfo, + tc AccountTestCase) db.AccountInfo { + + t.Helper() + + i := slices.IndexFunc(accounts, func(acc db.AccountInfo) bool { + return acc.AccountName == tc.Name && acc.KeyScope == tc.Scope + }) + require.GreaterOrEqual(t, i, 0, "expected account %s in list", tc.Name) + + return accounts[i] +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 4f29aff1c7..666b32c444 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -24,6 +24,17 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // Enable foreign keys (required for proper constraint enforcement). dsn := dbPath + "?_pragma=foreign_keys=on" + // Enable WAL mode for better concurrency. WAL allows multiple readers and + // reduces lock contention for concurrent writers. + dsn = dsn + "&_pragma=journal_mode=WAL" + + // Enable immediate transaction locking to avoid races. + dsn = dsn + "&_txlock=immediate" + + // Set busy timeout to 5 seconds. This makes SQLite retry acquiring locks + // instead of immediately returning SQLITE_BUSY errors. + dsn = dsn + "&_pragma=busy_timeout=5000" + // TODO(gustavostingelin): replace with the real SQLite database // connection constructor when available. dbConn, err := sql.Open("sqlite", dsn) From 3ef61af8fa5e7870ddcd5ef039065354118f6282 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 2 Jan 2026 15:07:12 -0300 Subject: [PATCH 059/695] wallet: add derive max account number test --- .../internal/db/itest/account_store_test.go | 39 +++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 32 +++++++++++++++ .../internal/db/itest/fixtures_sqlite_test.go | 32 +++++++++++++++ .../db/queries/postgres/key_scopes.sql | 7 ++++ .../internal/db/queries/sqlite/key_scopes.sql | 7 ++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++++ .../db/sqlc/postgres/key_scopes.sql.go | 18 +++++++++ wallet/internal/db/sqlc/postgres/querier.go | 3 ++ wallet/internal/db/sqlc/sqlite/db.go | 10 +++++ .../internal/db/sqlc/sqlite/key_scopes.sql.go | 18 +++++++++ wallet/internal/db/sqlc/sqlite/querier.go | 3 ++ 11 files changed, 179 insertions(+) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index c16263716c..3931223800 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -4,6 +4,8 @@ package itest import ( "context" + "fmt" + "math" "slices" "sort" "strconv" @@ -659,6 +661,43 @@ func TestRenameAccountErrors(t *testing.T) { } } +// TestCreateDerivedAccountMaxAccountNumber verifies that CreateDerivedAccount +// returns ErrMaxAccountNumberReached when the account number counter exceeds +// the maximum uint32 value. +func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-account") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + SetLastAccountNumber(t, queries, scopeID, math.MaxUint32-1) + + // This should succeed with account_number = MaxUint32. + info, err := store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "account-max", + }, + ) + require.NoError(t, err) + require.Equal(t, uint32(math.MaxUint32), info.AccountNumber) + + // This should fail; the next allocation would be MaxUint32 + 1, which + // overflows uint32. + _, err = store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "account-overflow", + }, + ) + require.ErrorIs(t, err, db.ErrMaxAccountNumberReached) +} + +// newWallet creates a new wallet with the given name using the provided +// store and returns its ID. func newWallet(t *testing.T, store db.WalletStore, name string) uint32 { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 02c54504c3..87662523da 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -27,3 +27,35 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, return block } + +// SetLastAccountNumber sets the last_account_number for a key scope. +// Used to test account number overflow without creating billions of accounts. +func SetLastAccountNumber(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, lastAccountNumber int64) { + t.Helper() + + err := queries.SetLastAccountNumber( + t.Context(), sqlcpg.SetLastAccountNumberParams{ + LastAccountNumber: lastAccountNumber, + ID: scopeID, + }, + ) + require.NoError(t, err) +} + +// GetKeyScopeID retrieves the scope ID for a given wallet and key scope. +func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, + walletID uint32, scope db.KeyScope) int64 { + t.Helper() + + row, err := queries.GetKeyScopeByWalletAndScope( + t.Context(), sqlcpg.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, + ) + require.NoError(t, err) + + return row.ID +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 3881ad6328..92e6bc1812 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -27,3 +27,35 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, return block } + +// SetLastAccountNumber sets the last_account_number for a key scope. +// Used to test account number overflow without creating billions of accounts. +func SetLastAccountNumber(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, lastAccountNumber int64) { + t.Helper() + + err := queries.SetLastAccountNumber( + t.Context(), sqlcsqlite.SetLastAccountNumberParams{ + LastAccountNumber: lastAccountNumber, + ID: scopeID, + }, + ) + require.NoError(t, err) +} + +// GetKeyScopeID retrieves the scope ID for a given wallet and key scope. +func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, + walletID uint32, scope db.KeyScope) int64 { + t.Helper() + + row, err := queries.GetKeyScopeByWalletAndScope( + t.Context(), sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, + ) + require.NoError(t, err) + + return row.ID +} diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index 5b9d31fb01..7a3200035a 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -80,3 +80,10 @@ WHERE scope_id = $1; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = $1; + +-- name: SetLastAccountNumber :exec +-- Sets the last_account_number for a key scope. This is intended for testing +-- the account number overflow behavior without creating billions of accounts. +UPDATE key_scopes +SET last_account_number = $1 +WHERE id = $2; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 1635336b66..17edfe779e 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -91,3 +91,10 @@ WHERE scope_id = ?; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = ?; + +-- name: SetLastAccountNumber :exec +-- Sets the last_account_number for a key scope. This is intended for testing +-- the account number overflow behavior without creating billions of accounts. +UPDATE key_scopes +SET last_account_number = ? +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 22925ed29e..eecbb41552 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -120,6 +120,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -297,6 +300,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.setLastAccountNumberStmt != nil { + if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -388,6 +396,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt + setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -430,6 +439,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, + setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index a7e3e5968f..60dfb27b24 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -252,3 +252,21 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } + +const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec +UPDATE key_scopes +SET last_account_number = $1 +WHERE id = $2 +` + +type SetLastAccountNumberParams struct { + LastAccountNumber int64 + ID int64 +} + +// Sets the last_account_number for a key scope. This is intended for testing +// the account number overflow behavior without creating billions of accounts. +func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { + _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) + return err +} diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 41471588e8..233c578e7a 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -73,6 +73,9 @@ type Querier interface { // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Sets the last_account_number for a key scope. This is intended for testing + // the account number overflow behavior without creating billions of accounts. + SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 789f34aaa0..a202663a0e 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -123,6 +123,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -305,6 +308,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.setLastAccountNumberStmt != nil { + if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -397,6 +405,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt + setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -440,6 +449,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, + setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index e464c5ce8d..df22bbd344 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -276,3 +276,21 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } + +const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec +UPDATE key_scopes +SET last_account_number = ? +WHERE id = ? +` + +type SetLastAccountNumberParams struct { + LastAccountNumber int64 + ID int64 +} + +// Sets the last_account_number for a key scope. This is intended for testing +// the account number overflow behavior without creating billions of accounts. +func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { + _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) + return err +} diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index b05c8a8ac9..9d6877b17b 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -86,6 +86,9 @@ type Querier interface { // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Sets the last_account_number for a key scope. This is intended for testing + // the account number overflow behavior without creating billions of accounts. + SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. From 93efdbedd9eb2def93291a55036ef297c59fa704 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 19 Jan 2026 00:29:53 -0300 Subject: [PATCH 060/695] wallet: replace last_account_number counter with dynamic MAX computation --- wallet/internal/db/accounts_pg.go | 14 ++- wallet/internal/db/accounts_sqlite.go | 45 ++----- .../internal/db/itest/account_store_test.go | 2 +- wallet/internal/db/itest/fixtures_pg_test.go | 18 +-- .../internal/db/itest/fixtures_sqlite_test.go | 18 +-- .../postgres/000004_key_scopes.up.sql | 8 -- .../sqlite/000004_key_scopes.up.sql | 8 -- .../internal/db/queries/postgres/accounts.sql | 75 ++++++++---- .../db/queries/postgres/key_scopes.sql | 7 -- .../internal/db/queries/sqlite/accounts.sql | 34 ++++-- .../internal/db/queries/sqlite/key_scopes.sql | 18 --- .../internal/db/sqlc/postgres/accounts.sql.go | 112 ++++++++++++++---- wallet/internal/db/sqlc/postgres/db.go | 24 ++-- .../db/sqlc/postgres/key_scopes.sql.go | 62 ++-------- wallet/internal/db/sqlc/postgres/models.go | 1 - wallet/internal/db/sqlc/postgres/querier.go | 44 +++++-- .../internal/db/sqlc/sqlite/accounts.sql.go | 64 +++++++--- wallet/internal/db/sqlc/sqlite/db.go | 30 ++--- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 86 ++------------ wallet/internal/db/sqlc/sqlite/models.go | 1 - wallet/internal/db/sqlc/sqlite/querier.go | 32 ++--- 21 files changed, 342 insertions(+), 361 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index fd9f3dfcf0..b8d6e17ff8 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -66,9 +66,19 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, return err } + // Acquire an advisory lock for this scope to serialize account creation + // and prevent race conditions when computing MAX(account_number). This + // MUST be a separate statement that completes before + // qtx.CreateDerivedAccount runs. See the LockAccountScope comments for + // why single-statement approaches don't work. + err = qtx.LockAccountScope(ctx, scopeID) + if err != nil { + return fmt.Errorf("lock account scope: %w", err) + } + row, err := qtx.CreateDerivedAccount( ctx, sqlcpg.CreateDerivedAccountParams{ - ID: scopeID, + ScopeID: scopeID, AccountName: params.Name, OriginID: int16(DerivedAccount), IsWatchOnly: false, @@ -226,7 +236,7 @@ func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ), } }, - func(row sqlcpg.GetKeyScopeByWalletAndScopeRow) int64 { + func(row sqlcpg.KeyScope) int64 { return row.ID }, scope, ) diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index c099ac47ee..94f665c1a0 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -66,8 +66,13 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, return err } - row, err := sqliteAllocateAndCreateAccount( - ctx, qtx, scopeID, params.Name, + row, err := qtx.CreateDerivedAccount( + ctx, sqlcsqlite.CreateDerivedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int64(DerivedAccount), + IsWatchOnly: false, + }, ) if err != nil { return fmt.Errorf("create account: %w", err) @@ -124,44 +129,10 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, ), } }, - func(row sqlcsqlite.GetKeyScopeByWalletAndScopeRow) int64 { - return row.ID - }, scope, + func(row sqlcsqlite.KeyScope) int64 { return row.ID }, scope, ) } -// sqliteAllocateAndCreateAccount allocates a new sequential account number and -// creates a derived account in a single atomic operation. SQLite requires a -// two-step process because it lacks PostgreSQL's UPDATE ... RETURNING clause. -func sqliteAllocateAndCreateAccount(ctx context.Context, - qtx *sqlcsqlite.Queries, scopeID int64, - accountName string) (sqlcsqlite.CreateDerivedAccountRow, error) { - - allocated, err := qtx.AllocateAccountNumber(ctx, scopeID) - if err != nil { - return sqlcsqlite.CreateDerivedAccountRow{}, - fmt.Errorf("allocate account number: %w", err) - } - - row, err := qtx.CreateDerivedAccount(ctx, - sqlcsqlite.CreateDerivedAccountParams{ - ScopeID: scopeID, - AccountNumber: sql.NullInt64{ - Int64: allocated.LastAccountNumber, - Valid: true, - }, - AccountName: accountName, - OriginID: int64(DerivedAccount), - IsWatchOnly: false, - }) - if err != nil { - return sqlcsqlite.CreateDerivedAccountRow{}, - fmt.Errorf("create account: %w", err) - } - - return row, nil -} - // CreateImportedAccount stores an imported account identified by an extended // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 3931223800..02789da0b8 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -671,7 +671,7 @@ func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { walletID := newWallet(t, store, "wallet-max-account") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - SetLastAccountNumber(t, queries, scopeID, math.MaxUint32-1) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") // This should succeed with account_number = MaxUint32. info, err := store.CreateDerivedAccount( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 87662523da..6fc07f9446 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -3,6 +3,7 @@ package itest import ( + "database/sql" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -28,16 +29,19 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, return block } -// SetLastAccountNumber sets the last_account_number for a key scope. +// CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func SetLastAccountNumber(t *testing.T, queries *sqlcpg.Queries, - scopeID int64, lastAccountNumber int64) { +func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, accountNumber uint32, name string) { t.Helper() - err := queries.SetLastAccountNumber( - t.Context(), sqlcpg.SetLastAccountNumberParams{ - LastAccountNumber: lastAccountNumber, - ID: scopeID, + _, err := queries.CreateDerivedAccountWithNumber( + t.Context(), sqlcpg.CreateDerivedAccountWithNumberParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, + AccountName: name, + OriginID: int16(db.DerivedAccount), + IsWatchOnly: false, }, ) require.NoError(t, err) diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 92e6bc1812..3d5dfcf676 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -3,6 +3,7 @@ package itest import ( + "database/sql" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -28,16 +29,19 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, return block } -// SetLastAccountNumber sets the last_account_number for a key scope. +// CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func SetLastAccountNumber(t *testing.T, queries *sqlcsqlite.Queries, - scopeID int64, lastAccountNumber int64) { +func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, accountNumber uint32, name string) { t.Helper() - err := queries.SetLastAccountNumber( - t.Context(), sqlcsqlite.SetLastAccountNumberParams{ - LastAccountNumber: lastAccountNumber, - ID: scopeID, + _, err := queries.CreateDerivedAccountWithNumber( + t.Context(), sqlcsqlite.CreateDerivedAccountWithNumberParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, + AccountName: name, + OriginID: int64(db.DerivedAccount), + IsWatchOnly: false, }, ) require.NoError(t, err) diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index 9af8cabd0a..1162ebd008 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -27,14 +27,6 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id SMALLINT NOT NULL, - -- Counter used to allocate sequential account numbers within this scope. - -- This avoids scanning the accounts table to compute MAX(account_number). - -- The value is updated atomically via UPDATE with RETURNING, which allows - -- concurrent account creation without additional locking logic. - -- The counter starts at minus one. Each new account consumes the current - -- value plus one, then stores the updated value for the next allocation. - last_account_number BIGINT NOT NULL DEFAULT -1, - -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 37432efdfc..702758d4ab 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -27,14 +27,6 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id INTEGER NOT NULL, - -- Counter used to allocate sequential account numbers within this scope. - -- This avoids scanning the accounts table to compute MAX(account_number). - -- The value is updated atomically via UPDATE with RETURNING, which allows - -- concurrent account creation without additional locking logic. - -- The counter starts at minus one. Each new account consumes the current - -- value plus one, then stores the updated value for the next allocation. - last_account_number INTEGER NOT NULL DEFAULT -1, - -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index bf5f02ed9f..9538e355f8 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -1,15 +1,35 @@ --- name: CreateDerivedAccount :one --- Creates a new derived account under the given scope, allocating a fresh --- sequential account number from key_scopes.last_account_number. --- The allocation is atomic: the UPDATE takes the row lock on the scope row, --- returns the allocated number, and updates the counter for the next call. -WITH allocated_number AS ( - UPDATE key_scopes - SET last_account_number = last_account_number + 1 - WHERE key_scopes.id = $1 - RETURNING key_scopes.id, last_account_number AS account_number -) +-- name: LockAccountScope :exec +-- Acquires a transaction-level advisory lock to serialize account creation within a scope. +-- The lock is automatically released upon transaction commit or rollback. +-- This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. +-- +-- We explicitly use a two-statement pattern because single-statement CTE/Join +-- approaches failed to prevent race conditions during concurrent account generation. +-- The following "one-query" strategies were tested and proven unreliable: +-- +-- 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the +-- MAX(account_number) subquery using a snapshot taken before the lock CTE +-- is fully processed, leading to duplicate account numbers. +-- +-- 2. CTE with OFFSET 0: Designed to force materialization, this still fails to +-- guarantee that the lock is held before the aggregate subquery begins its +-- read operation. +-- +-- 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails +-- to "lock the gap" for new inserts or handle empty tables, allowing +-- concurrent processes to calculate identical MAX() values. +-- +-- Using two separate calls ensures the application pauses until +-- LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() +-- operates inside a strictly serialized execution window for that scope. +SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)); + +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, computing the next +-- account number atomically. The caller MUST call LockAccountScope first +-- to acquire the advisory lock and prevent race conditions. +-- See LockAccountScope comments for why this is a separate statement. INSERT INTO accounts ( scope_id, account_number, @@ -19,16 +39,16 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -SELECT - allocated_number.id AS scope_id, - allocated_number.account_number, - $2 AS account_name, - $3 AS origin_id, - $4 AS encrypted_public_key, - $5 AS master_fingerprint, - $6 AS is_watch_only -FROM allocated_number -RETURNING accounts.id, accounts.account_number, accounts.created_at; +VALUES ( + $1, + ( + SELECT coalesce(max(account_number), -1) + 1 + FROM accounts + WHERE scope_id = $1 + ), + $2, $3, $4, $5, $6 +) +RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one -- Creates a new imported account under the given scope with NULL account @@ -233,3 +253,16 @@ WHERE AND coin_type = sqlc.arg(coin_type) ) AND account_name = sqlc.arg(old_name); + +-- name: CreateDerivedAccountWithNumber :one +-- Test-only: Creates a derived account with a specific account number. +-- Used for testing account number overflow without creating billions of accounts. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, account_number, created_at; diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index 7a3200035a..5b9d31fb01 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -80,10 +80,3 @@ WHERE scope_id = $1; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = $1; - --- name: SetLastAccountNumber :exec --- Sets the last_account_number for a key scope. This is intended for testing --- the account number overflow behavior without creating billions of accounts. -UPDATE key_scopes -SET last_account_number = $1 -WHERE id = $2; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index 5d033dec88..f325be0520 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -1,15 +1,7 @@ -- name: CreateDerivedAccount :one --- Creates a new derived account under the given scope, using a caller-provided --- account number. --- --- NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation --- step with an INSERT in a single CTE. --- --- We instead: --- 1) call AllocateAccountNumber (key_scopes.sql) --- 2) call CreateDerivedAccount with the returned number --- --- Both statements run within the same SQL transaction. +-- Creates a new derived account under the given scope, computing the next +-- account number from existing accounts. SQLite's _txlock=immediate ensures +-- only one writer at a time, preventing concurrent allocation conflicts. INSERT INTO accounts ( scope_id, account_number, @@ -20,7 +12,12 @@ INSERT INTO accounts ( is_watch_only ) VALUES ( - ?, ?, ?, ?, ?, ?, ? + ?1, + ( + SELECT coalesce(max(account_number), -1) + 1 FROM accounts + WHERE scope_id = ?1 + ), + ?2, ?3, ?4, ?5, ?6 ) RETURNING id, account_number, created_at; @@ -227,3 +224,16 @@ WHERE AND coin_type = sqlc.arg(coin_type) ) AND account_name = sqlc.arg(old_name); + +-- name: CreateDerivedAccountWithNumber :one +-- Test-only: Creates a derived account with a specific account number. +-- Used for testing account number overflow without creating billions of accounts. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES (?, ?, ?, ?, ?) +RETURNING id, account_number, created_at; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 17edfe779e..4f372584e9 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -49,17 +49,6 @@ SELECT FROM key_scopes WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; --- name: AllocateAccountNumber :one --- Atomically allocates the next account number for a key scope. --- Returns the scope_id and the allocated account number. --- SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single --- CTE (unlike Postgres). So, we call this first, then pass the returned number --- to CreateAccount, within the same SQL transaction. -UPDATE key_scopes -SET last_account_number = last_account_number + 1 -WHERE id = ? -RETURNING id, last_account_number; - -- name: ListKeyScopesByWallet :many -- Lists all key scopes for a wallet, ordered by ID. SELECT @@ -91,10 +80,3 @@ WHERE scope_id = ?; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = ?; - --- name: SetLastAccountNumber :exec --- Sets the last_account_number for a key scope. This is intended for testing --- the account number overflow behavior without creating billions of accounts. -UPDATE key_scopes -SET last_account_number = ? -WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b5b5de9ade..0d2e667312 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -32,13 +32,6 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr } const CreateDerivedAccount = `-- name: CreateDerivedAccount :one -WITH allocated_number AS ( - UPDATE key_scopes - SET last_account_number = last_account_number + 1 - WHERE key_scopes.id = $1 - RETURNING key_scopes.id, last_account_number AS account_number -) - INSERT INTO accounts ( scope_id, account_number, @@ -48,20 +41,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -SELECT - allocated_number.id AS scope_id, - allocated_number.account_number, - $2 AS account_name, - $3 AS origin_id, - $4 AS encrypted_public_key, - $5 AS master_fingerprint, - $6 AS is_watch_only -FROM allocated_number -RETURNING accounts.id, accounts.account_number, accounts.created_at +VALUES ( + $1, + ( + SELECT coalesce(max(account_number), -1) + 1 + FROM accounts + WHERE scope_id = $1 + ), + $2, $3, $4, $5, $6 +) +RETURNING id, account_number, created_at ` type CreateDerivedAccountParams struct { - ID int64 + ScopeID int64 AccountName string OriginID int16 EncryptedPublicKey []byte @@ -75,13 +68,13 @@ type CreateDerivedAccountRow struct { CreatedAt time.Time } -// Creates a new derived account under the given scope, allocating a fresh -// sequential account number from key_scopes.last_account_number. -// The allocation is atomic: the UPDATE takes the row lock on the scope row, -// returns the allocated number, and updates the counter for the next call. +// Creates a new derived account under the given scope, computing the next +// account number atomically. The caller MUST call LockAccountScope first +// to acquire the advisory lock and prevent race conditions. +// See LockAccountScope comments for why this is a separate statement. func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, - arg.ID, + arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, @@ -93,6 +86,47 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc return i, err } +const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountWithNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool +} + +type CreateDerivedAccountWithNumberRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Test-only: Creates a derived account with a specific account number. +// Used for testing account number overflow without creating billions of accounts. +func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountWithNumberRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( scope_id, @@ -638,6 +672,38 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount return items, nil } +const LockAccountScope = `-- name: LockAccountScope :exec +SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)) +` + +// Acquires a transaction-level advisory lock to serialize account creation within a scope. +// The lock is automatically released upon transaction commit or rollback. +// This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. +// +// We explicitly use a two-statement pattern because single-statement CTE/Join +// approaches failed to prevent race conditions during concurrent account generation. +// The following "one-query" strategies were tested and proven unreliable: +// +// 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the +// MAX(account_number) subquery using a snapshot taken before the lock CTE +// is fully processed, leading to duplicate account numbers. +// +// 2. CTE with OFFSET 0: Designed to force materialization, this still fails to +// guarantee that the lock is held before the aggregate subquery begins its +// read operation. +// +// 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails +// to "lock the gap" for new inserts or handle empty tables, allowing +// concurrent processes to calculate identical MAX() values. +// +// Using two separate calls ensures the application pauses until +// LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() +// operates inside a strictly serialized execution window for that scope. +func (q *Queries) LockAccountScope(ctx context.Context, dollar_1 int64) error { + _, err := q.exec(ctx, q.lockAccountScopeStmt, LockAccountScope, dollar_1) + return err +} + const UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows UPDATE accounts SET account_name = $1 diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index eecbb41552..7f9d3d60c6 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) } + if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } @@ -120,8 +123,8 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } - if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + if q.lockAccountScopeStmt, err = db.PrepareContext(ctx, LockAccountScope); err != nil { + return nil, fmt.Errorf("error preparing query LockAccountScope: %w", err) } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) @@ -150,6 +153,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) } } + if q.createDerivedAccountWithNumberStmt != nil { + if cerr := q.createDerivedAccountWithNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) @@ -300,9 +308,9 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } - if q.setLastAccountNumberStmt != nil { - if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + if q.lockAccountScopeStmt != nil { + if cerr := q.lockAccountScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing lockAccountScopeStmt: %w", cerr) } } if q.updateAccountNameByWalletScopeAndNameStmt != nil { @@ -366,6 +374,7 @@ type Queries struct { tx *sql.Tx createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt + createDerivedAccountWithNumberStmt *sql.Stmt createImportedAccountStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt @@ -396,7 +405,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt - setLastAccountNumberStmt *sql.Stmt + lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -409,6 +418,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, + createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, createImportedAccountStmt: q.createImportedAccountStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, @@ -439,7 +449,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, - setLastAccountNumberStmt: q.setLastAccountNumberStmt, + lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 60dfb27b24..2afd0e7aaa 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -89,20 +89,10 @@ FROM key_scopes WHERE id = $1 ` -type GetKeyScopeByIDRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i GetKeyScopeByIDRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -134,20 +124,10 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } -type GetKeyScopeByWalletAndScopeRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i GetKeyScopeByWalletAndScopeRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -211,26 +191,16 @@ WHERE wallet_id = $1 ORDER BY id ` -type ListKeyScopesByWalletRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []ListKeyScopesByWalletRow + var items []KeyScope for rows.Next() { - var i ListKeyScopesByWalletRow + var i KeyScope if err := rows.Scan( &i.ID, &i.WalletID, @@ -252,21 +222,3 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } - -const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec -UPDATE key_scopes -SET last_account_number = $1 -WHERE id = $2 -` - -type SetLastAccountNumberParams struct { - LastAccountNumber int64 - ID int64 -} - -// Sets the last_account_number for a key scope. This is intended for testing -// the account number overflow behavior without creating billions of accounts. -func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { - _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) - return err -} diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 2957b82a6e..4c4bdc252c 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -50,7 +50,6 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int16 ExternalTypeID int16 - LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 233c578e7a..114ed39acf 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -11,11 +11,14 @@ import ( type Querier interface { // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error - // Creates a new derived account under the given scope, allocating a fresh - // sequential account number from key_scopes.last_account_number. - // The allocation is atomic: the UPDATE takes the row lock on the scope row, - // returns the allocated number, and updates the counter for the next call. + // Creates a new derived account under the given scope, computing the next + // account number atomically. The caller MUST call LockAccountScope first + // to acquire the advisory lock and prevent race conditions. + // See LockAccountScope comments for why this is a separate statement. CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Test-only: Creates a derived account with a specific account number. + // Used for testing account number overflow without creating billions of accounts. + CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. @@ -42,9 +45,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -71,11 +74,32 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) - // Sets the last_account_number for a key scope. This is intended for testing - // the account number overflow behavior without creating billions of accounts. - SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error + // Acquires a transaction-level advisory lock to serialize account creation within a scope. + // The lock is automatically released upon transaction commit or rollback. + // This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. + // + // We explicitly use a two-statement pattern because single-statement CTE/Join + // approaches failed to prevent race conditions during concurrent account generation. + // The following "one-query" strategies were tested and proven unreliable: + // + // 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the + // MAX(account_number) subquery using a snapshot taken before the lock CTE + // is fully processed, leading to duplicate account numbers. + // + // 2. CTE with OFFSET 0: Designed to force materialization, this still fails to + // guarantee that the lock is held before the aggregate subquery begins its + // read operation. + // + // 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails + // to "lock the gap" for new inserts or handle empty tables, allowing + // concurrent processes to calculate identical MAX() values. + // + // Using two separate calls ensures the application pauses until + // LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() + // operates inside a strictly serialized execution window for that scope. + LockAccountScope(ctx context.Context, dollar_1 int64) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 6e4cd25831..90a939d402 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -42,14 +42,18 @@ INSERT INTO accounts ( is_watch_only ) VALUES ( - ?, ?, ?, ?, ?, ?, ? + ?1, + ( + SELECT coalesce(max(account_number), -1) + 1 FROM accounts + WHERE scope_id = ?1 + ), + ?2, ?3, ?4, ?5, ?6 ) RETURNING id, account_number, created_at ` type CreateDerivedAccountParams struct { ScopeID int64 - AccountNumber sql.NullInt64 AccountName string OriginID int64 EncryptedPublicKey []byte @@ -63,21 +67,12 @@ type CreateDerivedAccountRow struct { CreatedAt time.Time } -// Creates a new derived account under the given scope, using a caller-provided -// account number. -// -// NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation -// step with an INSERT in a single CTE. -// -// We instead: -// 1. call AllocateAccountNumber (key_scopes.sql) -// 2. call CreateDerivedAccount with the returned number -// -// Both statements run within the same SQL transaction. +// Creates a new derived account under the given scope, computing the next +// account number from existing accounts. SQLite's _txlock=immediate ensures +// only one writer at a time, preventing concurrent allocation conflicts. func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, arg.ScopeID, - arg.AccountNumber, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, @@ -89,6 +84,47 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc return i, err } +const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES (?, ?, ?, ?, ?) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountWithNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool +} + +type CreateDerivedAccountWithNumberRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Test-only: Creates a derived account with a specific account number. +// Used for testing account number overflow without creating billions of accounts. +func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountWithNumberRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( scope_id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index a202663a0e..2b4a10c8cd 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,15 +24,15 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error - if q.allocateAccountNumberStmt, err = db.PrepareContext(ctx, AllocateAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query AllocateAccountNumber: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) } + if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } @@ -123,9 +123,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } - if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) - } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -143,11 +140,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error - if q.allocateAccountNumberStmt != nil { - if cerr := q.allocateAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing allocateAccountNumberStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -158,6 +150,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) } } + if q.createDerivedAccountWithNumberStmt != nil { + if cerr := q.createDerivedAccountWithNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) @@ -308,11 +305,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } - if q.setLastAccountNumberStmt != nil { - if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) - } - } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -372,9 +364,9 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx - allocateAccountNumberStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt + createDerivedAccountWithNumberStmt *sql.Stmt createImportedAccountStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt @@ -405,7 +397,6 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt - setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -416,9 +407,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, - allocateAccountNumberStmt: q.allocateAccountNumberStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, + createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, createImportedAccountStmt: q.createImportedAccountStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, @@ -449,7 +440,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, - setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index df22bbd344..7305a3c014 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -9,30 +9,6 @@ import ( "context" ) -const AllocateAccountNumber = `-- name: AllocateAccountNumber :one -UPDATE key_scopes -SET last_account_number = last_account_number + 1 -WHERE id = ? -RETURNING id, last_account_number -` - -type AllocateAccountNumberRow struct { - ID int64 - LastAccountNumber int64 -} - -// Atomically allocates the next account number for a key scope. -// Returns the scope_id and the allocated account number. -// SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single -// CTE (unlike Postgres). So, we call this first, then pass the returned number -// to CreateAccount, within the same SQL transaction. -func (q *Queries) AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) { - row := q.queryRow(ctx, q.allocateAccountNumberStmt, AllocateAccountNumber, id) - var i AllocateAccountNumberRow - err := row.Scan(&i.ID, &i.LastAccountNumber) - return i, err -} - const CreateKeyScope = `-- name: CreateKeyScope :one INSERT INTO key_scopes ( wallet_id, @@ -113,20 +89,10 @@ FROM key_scopes WHERE id = ? ` -type GetKeyScopeByIDRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i GetKeyScopeByIDRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -158,20 +124,10 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } -type GetKeyScopeByWalletAndScopeRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i GetKeyScopeByWalletAndScopeRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -235,26 +191,16 @@ WHERE wallet_id = ? ORDER BY id ` -type ListKeyScopesByWalletRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []ListKeyScopesByWalletRow + var items []KeyScope for rows.Next() { - var i ListKeyScopesByWalletRow + var i KeyScope if err := rows.Scan( &i.ID, &i.WalletID, @@ -276,21 +222,3 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } - -const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec -UPDATE key_scopes -SET last_account_number = ? -WHERE id = ? -` - -type SetLastAccountNumberParams struct { - LastAccountNumber int64 - ID int64 -} - -// Sets the last_account_number for a key scope. This is intended for testing -// the account number overflow behavior without creating billions of accounts. -func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { - _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) - return err -} diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 47199cd608..9427d3abb3 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -50,7 +50,6 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int64 ExternalTypeID int64 - LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 9d6877b17b..742741b180 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,26 +9,15 @@ import ( ) type Querier interface { - // Atomically allocates the next account number for a key scope. - // Returns the scope_id and the allocated account number. - // SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single - // CTE (unlike Postgres). So, we call this first, then pass the returned number - // to CreateAccount, within the same SQL transaction. - AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error - // Creates a new derived account under the given scope, using a caller-provided - // account number. - // - // NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation - // step with an INSERT in a single CTE. - // - // We instead: - // 1) call AllocateAccountNumber (key_scopes.sql) - // 2) call CreateDerivedAccount with the returned number - // - // Both statements run within the same SQL transaction. + // Creates a new derived account under the given scope, computing the next + // account number from existing accounts. SQLite's _txlock=immediate ensures + // only one writer at a time, preventing concurrent allocation conflicts. CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Test-only: Creates a derived account with a specific account number. + // Used for testing account number overflow without creating billions of accounts. + CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. @@ -55,9 +44,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -84,11 +73,8 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) - // Sets the last_account_number for a key scope. This is intended for testing - // the account number overflow behavior without creating billions of accounts. - SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. From ac399a26c01d6b033acfb6c0eda8cd2fb5fde33c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:04:01 -0300 Subject: [PATCH 061/695] scripts: add db coverage filter --- scripts/filter_coverage.sh | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100755 scripts/filter_coverage.sh diff --git a/scripts/filter_coverage.sh b/scripts/filter_coverage.sh new file mode 100755 index 0000000000..2696c04b00 --- /dev/null +++ b/scripts/filter_coverage.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Filter coverage files to exclude opposite backend implementations +# Usage: filter_coverage.sh +# Where db_type is 'sqlite' or 'postgres' + +set -e + +DB_TYPE="$1" +if [ "$DB_TYPE" != "sqlite" ] && [ "$DB_TYPE" != "postgres" ]; then + echo "Usage: $0 " + exit 1 +fi + +COVERAGE_FILE="coverage-itest-${DB_TYPE}.txt" +if [ ! -f "$COVERAGE_FILE" ]; then + echo "Coverage file $COVERAGE_FILE not found" + exit 1 +fi + +# Create filtered version +FILTERED_FILE="${COVERAGE_FILE}.filtered" + +# Keep the mode line, filter out opposite backend files +head -1 "$COVERAGE_FILE" >"$FILTERED_FILE" + +if [ "$DB_TYPE" = "sqlite" ]; then + # For sqlite: exclude postgres files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'pg|postgres' >>"$FILTERED_FILE" +else + # For postgres: exclude sqlite files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'sqlite' >>"$FILTERED_FILE" +fi + +# Replace original with filtered +mv "$FILTERED_FILE" "$COVERAGE_FILE" + +# Output the filtered coverage percentage +go tool cover -func="$COVERAGE_FILE" | + awk '/^total:/ { print "Filtered test coverage for '"$DB_TYPE"': " $3 }' From 34010eb5d88a0c407ace162f0d5aaf5511eab038 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:05:01 -0300 Subject: [PATCH 062/695] Make: add cover option to itest-db --- Makefile | 4 ++++ config/testing_flags.mk | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6463ea5cd2..38a7ed583b 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,10 @@ itest-db: fi @$(call print, "Running $(IT_DB_LABEL) integration tests.") $(ITEST_DB) + @if [ -n "$(ITEST_DB_COVERPROFILE)" ]; then \ + echo "Filtering coverage report."; \ + ./scripts/filter_coverage.sh $(IT_DB_TYPE); \ + fi #? itest-db-race: Run integration tests for wallet database with race detector itest-db-race: diff --git a/config/testing_flags.mk b/config/testing_flags.mk index 0c7951107e..6fae8ee206 100644 --- a/config/testing_flags.mk +++ b/config/testing_flags.mk @@ -7,9 +7,17 @@ IT_TAGS ?= ifeq ($(db),postgres) IT_TAGS += test_db_postgres IT_DB_LABEL := PostgreSQL +IT_DB_TYPE := postgres else IT_TAGS := IT_DB_LABEL := SQLite +IT_DB_TYPE := sqlite +endif + +# Enable integration test coverage +ifeq ($(cover),1) +ITEST_DB_COVERPROFILE = coverage-itest-$(IT_DB_TYPE).txt +ITEST_DB_COVERAGE = -coverprofile=$(ITEST_DB_COVERPROFILE) -coverpkg=$(PKG)/wallet/internal/db/... -covermode=atomic endif GOCC ?= go @@ -91,5 +99,5 @@ endif UNIT_COVER := $(GOTEST) $(COVER_FLAGS) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) $(COVER_PKG) -ITEST_DB := $(GOTEST) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest +ITEST_DB := $(GOTEST) $(ITEST_DB_COVERAGE) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest ITEST_DB_RACE := $(GOTEST) -race -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest From 5c9af22e181ca0dfc2b4391fd310d010dfadd0f2 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:50:25 -0300 Subject: [PATCH 063/695] .gitignore: ignore itest coverage reports --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 25a01ef540..38ab05487f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ btcwallet vendor .idea coverage.txt +coverage-itest-postgres.txt +coverage-itest-sqlite.txt *.swp .vscode .DS_Store From 900e9948aecd7aea86557ebb82c7a1e351170106 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:51:05 -0300 Subject: [PATCH 064/695] CI: add coverallsapp action for itest --- .github/workflows/main.yml | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5cafec1db..45b53ae614 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -152,24 +152,26 @@ jobs: run: make ${{ matrix.unit_type }} - name: Send coverage - uses: shogo82148/actions-goveralls@v1 + uses: coverallsapp/github-action@v2 if: matrix.unit_type == 'unit-cover' continue-on-error: true with: - path-to-profile: coverage.txt + file: coverage.txt + flag-name: unit + format: golang parallel: true ######################## # run integration tests (SQLite + Postgres) ######################## itest-db: - name: integration tests (${{ matrix.db }}, ${{ matrix.target }}) + name: integration tests (${{ matrix.db }}, ${{ matrix.race && 'race' || 'cover' }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: db: [sqlite, postgres] - target: [itest-db, itest-db-race] + race: [false, true] steps: - name: git checkout uses: actions/checkout@v5 @@ -193,5 +195,35 @@ jobs: with: go-version: '${{ env.GO_VERSION }}' - - name: run ${{ matrix.db }} ${{ matrix.target }} - run: make ${{ matrix.target }} db=${{ matrix.db }} verbose=1 + - name: run ${{ matrix.db }} itest-db (coverage) + if: ${{ !matrix.race }} + run: make itest-db db=${{ matrix.db }} cover=1 verbose=1 + + - name: run ${{ matrix.db }} itest-db-race + if: matrix.race + run: make itest-db-race db=${{ matrix.db }} verbose=1 + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + if: ${{ !matrix.race }} + continue-on-error: true + with: + file: coverage-itest-${{ matrix.db }}.txt + flag-name: itest-db-${{ matrix.db }} + format: golang + parallel: true + + ######################## + # Complete parallel coverage uploads + ######################## + finish: + name: Finish coverage upload + if: ${{ !cancelled() }} + needs: [unit-test, itest-db] + runs-on: ubuntu-latest + steps: + - name: Finish parallel Coveralls upload + uses: coverallsapp/github-action@v2 + continue-on-error: true + with: + parallel-finished: true From 8d44cd36f72c13d48357e9136db0f84a79aa93b8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 11:48:56 -0300 Subject: [PATCH 065/695] multi: move tools config files back to root This reverts the previous change that relocated tool configuration files under the config directory. Several tools expect these files at the repository root by default, and moving them caused unnecessary friction and extra configuration. In Go projects, a config directory is typically used for application level configuration, such as code and schemas for parsing runtime or environment config files, not for external tooling like linters or formatters. Restore the files to the root to keep tool behavior predictable and aligned with common Go conventions. --- config/.golangci.yml => .golangci.yml | 0 config/.protolint.yml => .protolint.yml | 0 config/sqlfluff.cfg => .sqlfluff | 0 Makefile | 44 ++++++++++++------------- {config => make}/testing_flags.mk | 0 config/sqlc.yaml => sqlc.yaml | 12 +++---- 6 files changed, 28 insertions(+), 28 deletions(-) rename config/.golangci.yml => .golangci.yml (100%) rename config/.protolint.yml => .protolint.yml (100%) rename config/sqlfluff.cfg => .sqlfluff (100%) rename {config => make}/testing_flags.mk (100%) rename config/sqlc.yaml => sqlc.yaml (80%) diff --git a/config/.golangci.yml b/.golangci.yml similarity index 100% rename from config/.golangci.yml rename to .golangci.yml diff --git a/config/.protolint.yml b/.protolint.yml similarity index 100% rename from config/.protolint.yml rename to .protolint.yml diff --git a/config/sqlfluff.cfg b/.sqlfluff similarity index 100% rename from config/sqlfluff.cfg rename to .sqlfluff diff --git a/Makefile b/Makefile index 38a7ed583b..8155843712 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ CP := cp MAKE := make XARGS := xargs -L 1 -include config/testing_flags.mk +include make/testing_flags.mk # Linting uses a lot of memory, so keep it under control by limiting the number # of workers if requested. @@ -148,17 +148,17 @@ rpc-format: #? lint-config-check: Verify golangci-lint configuration lint-config-check: docker-tools @$(call print, "Verifying golangci-lint configuration.") - $(DOCKER_TOOLS) golangci-lint config verify -v --config config/.golangci.yml + $(DOCKER_TOOLS) golangci-lint config verify -v --config .golangci.yml #? lint: Lint source and check errors lint-check: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --config config/.golangci.yml $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --config .golangci.yml $(LINT_WORKERS) #? lint: Lint source and fix lint: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --fix --config config/.golangci.yml $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --fix --config .golangci.yml $(LINT_WORKERS) #? docker-tools: Build tools docker image docker-tools: @@ -178,7 +178,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - $(DOCKER_TOOLS) protolint lint -config_dir_path=config rpc/ + $(DOCKER_TOOLS) protolint lint -config_dir_path=. rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install @@ -202,18 +202,18 @@ tidy-module-check: tidy-module #? sql-parse: Ensures SQL files are syntactically valid sql-parse: @$(call print, "Validating SQL files (postgres migrations).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Validating SQL files (postgres queries).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Validating SQL files (sqlite migrations).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Validating SQL files (sqlite queries).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sqlc: Generate Go code from SQL queries and migrations sqlc: sql-parse docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f sqlc.yaml #? sqlc-check: Verify generated Go SQL queries and migrations are up-to-date sqlc-check: sqlc @@ -223,13 +223,13 @@ sqlc-check: sqlc #? sql-format: Format SQL migration and query files (like 'make fmt') sql-format: @$(call print, "Formatting SQL files (postgres migrations).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Formatting SQL files (postgres queries).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Formatting SQL files (sqlite migrations).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Formatting SQL files (sqlite queries).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') sql-format-check: sql-format @@ -239,24 +239,24 @@ sql-format-check: sql-format #? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') sql-lint: @$(call print, "Linting SQL files (postgres migrations).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Linting SQL files (postgres queries).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Linting SQL files (sqlite migrations).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Linting SQL files (sqlite queries).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sql-lint-check: Lint SQL files and report errors (like 'make lint-check') sql-lint-check: @$(call print, "Linting SQL files (postgres migrations).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Linting SQL files (postgres queries).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Linting SQL files (sqlite migrations).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Linting SQL files (sqlite queries).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) .PHONY: all \ default \ diff --git a/config/testing_flags.mk b/make/testing_flags.mk similarity index 100% rename from config/testing_flags.mk rename to make/testing_flags.mk diff --git a/config/sqlc.yaml b/sqlc.yaml similarity index 80% rename from config/sqlc.yaml rename to sqlc.yaml index 5e9f3a162b..3bed14788a 100644 --- a/config/sqlc.yaml +++ b/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "../wallet/internal/db/migrations/postgres" - queries: "../wallet/internal/db/queries/postgres" + schema: "wallet/internal/db/migrations/postgres" + queries: "wallet/internal/db/queries/postgres" gen: go: - out: "../wallet/internal/db/sqlc/postgres" + out: "wallet/internal/db/sqlc/postgres" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "../wallet/internal/db/migrations/sqlite" - queries: "../wallet/internal/db/queries/sqlite" + schema: "wallet/internal/db/migrations/sqlite" + queries: "wallet/internal/db/queries/sqlite" gen: go: - out: "../wallet/internal/db/sqlc/sqlite" + out: "wallet/internal/db/sqlc/sqlite" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. From e77be7f8a69556dfc37fbd0be8f3b1d267425893 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 12:29:31 -0300 Subject: [PATCH 066/695] Makefile: remove sqlfluff parse output noise As the number of SQL files in the project increased, the sqlfluff parse command started flooding stdout with low value logs, often one line per operator such as an equals sign in sql. This output is noisy for humans and also problematic for LLM based tools, since the excessive logs consume context length unnecessarily. Disable this parse output to keep the CLI readable. --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 8155843712..81582e266a 100644 --- a/Makefile +++ b/Makefile @@ -202,13 +202,13 @@ tidy-module-check: tidy-module #? sql-parse: Ensures SQL files are syntactically valid sql-parse: @$(call print, "Validating SQL files (postgres migrations).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) --format none @$(call print, "Validating SQL files (postgres queries).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) --format none @$(call print, "Validating SQL files (sqlite migrations).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) --format none @$(call print, "Validating SQL files (sqlite queries).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) --format none #? sqlc: Generate Go code from SQL queries and migrations sqlc: sql-parse docker-tools From 1b583a4047c830d02503723f1574c61df54d9e74 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 11 Jan 2026 20:12:50 -0300 Subject: [PATCH 067/695] wallet: fix typo --- wallet/internal/db/data_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 22a5df2ef5..385a9b3b43 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -443,7 +443,7 @@ type CreateImportedAccountParams struct { } // AccountProperties contains properties associated with each account, such as -// the account name, number, and the nubmer of derived and imported keys. +// the account name, number, and the number of derived and imported keys. type AccountProperties struct { // AccountNumber is the BIP44 account index used for derived accounts. // Imported accounts do not follow BIP44 derivation and therefore do not From bca4fcac5aec8d4e1137e4f6a22be5ed3d8d2b34 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 12 Jan 2026 12:48:00 -0300 Subject: [PATCH 068/695] wallet: refactor AddressStore interface to respect SRP --- wallet/internal/db/data_types.go | 176 ++++++++++++++----------------- wallet/internal/db/interface.go | 70 ++++++------ 2 files changed, 118 insertions(+), 128 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 385a9b3b43..227a35be56 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -7,7 +7,6 @@ import ( "time" "github.com/btcsuite/btcd/address/v2" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -168,18 +167,6 @@ const ( ImportedAccount ) -// Tapscript represents a Taproot script leaf, which includes the script itself -// and its corresponding control block. This is used for spending Taproot -// outputs. -type Tapscript struct { - // ControlBlock is the control block for the Taproot script, which is - // required to reveal the script path during spending. - ControlBlock []byte - - // Script is the actual script code of the Taproot leaf. - Script []byte -} - // -------------------- // WalletStore Types // -------------------- @@ -571,36 +558,69 @@ type RenameAccountParams struct { // AddressInfo represents a wallet-managed address, including its properties and // derivation information. type AddressInfo struct { - // Address is the human-readable address string. - Address address.Address + // ID is the database unique identifier for the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + ID uint32 - // Internal indicates whether the address is for internal (change) use. - Internal bool + // AccountID is the database unique identifier for the account this address + // belongs to. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + AccountID uint32 - // Compressed indicates whether the address is compressed. - Compressed bool + // AddrType is the type of address (P2PKH, P2WPKH, P2TR, etc.). + AddrType AddressType - // Used indicates whether the address has been used in a transaction. - Used bool + // CreatedAt is when the address was created in the wallet database. + CreatedAt time.Time - // IsWatchOnly indicates whether the wallet has the private key for - // this address. + // Origin indicates whether this is a derived HD address or an imported + // address. Reuses the AccountOrigin enum. + Origin AccountOrigin + + // Branch is the BIP44 branch number (0=external, 1=internal/change). + // Zero value for imported addresses. + Branch uint32 + + // Index is the BIP44 index within the branch. Zero value for imported + // addresses. + Index uint32 + + // ScriptPubKey is the script pubkey (plaintext). Zero value for + // derived addresses. + ScriptPubKey []byte + + // PubKey is the public key (plaintext). Zero value for derived + // addresses. + PubKey []byte + + // IsWatchOnly indicates whether the wallet has the private key for this + // address. Convenience field. IsWatchOnly bool +} - // AddrType is the type of the address (P2PKH, P2SH, etc.). - AddrType AddressType +// AddressSecret contains sensitive encrypted material for an address. +type AddressSecret struct { + // AddressID is the database unique identifier for the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + AddressID uint32 - // DerivationInfo contains the BIP-32 derivation path information for - // the address. This will be nil for imported addresses that are not - // part of an HD account. - DerivationInfo *DerivationInfo + // EncryptedPrivKey is the encrypted private key. + EncryptedPrivKey []byte - // Script is the script associated with the address, if any. - Script []byte + // EncryptedScript is the encrypted redeem or witness script for + // P2SH/P2WSH addresses. For Taproot, this is the TLV-encoded Tapscript. + EncryptedScript []byte } -// NewAddressParams contains the parameters for creating a new address. -type NewAddressParams struct { +// NewDerivedAddressParams contains the parameters for creating a new derived +// address. +type NewDerivedAddressParams struct { // WalletID is the ID of the wallet to create the address in. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -618,45 +638,39 @@ type NewAddressParams struct { Change bool } -// ImportAddressParams encapsulates all the data needed to store a new, imported -// address, script, or private key. All imported addresses are automatically -// assigned to the wallet's logical "imported" account. The presence of a -// private key determines whether the address will be spendable or watch-only. -type ImportAddressParams struct { - // WalletID is the ID of the wallet to import the address into. +// NewImportedAddressParams defines the input required to import a single +// address into the wallet. All imported addresses are assigned to the +// wallet imported account. The caller is responsible for encrypting any +// sensitive material before populating this struct. +type NewImportedAddressParams struct { + // WalletID identifies the wallet that will own this address. // // NOTE: uint32 is used to ensure compatibility with standard SQL // databases (signed 64-bit integers). WalletID uint32 - // PrivateKey is the private key to import, in WIF format. If this is - // provided, the address will be spendable. If nil, the import will be - // watch-only. - PrivateKey *btcutil.WIF + // Scope is the key scope for the imported address. + Scope KeyScope - // PubKey is the public key to import for a watch-only address. This - // field is only used if PrivateKey is nil. - PubKey *btcec.PublicKey + // AddressType specifies the address format being imported, such as + // P2PKH, P2WPKH, or P2TR. + AddressType AddressType - // Tapscript is the Taproot script to import for a watch-only address. - // This field is only used if PrivateKey is nil. - Tapscript *Tapscript + // ScriptPubKey contains the script pubkey associated with the address + // (stored in plaintext). + ScriptPubKey []byte - // Script is the generic script to import for a watch-only address. - // This field is only used if PrivateKey is nil. - Script []byte -} + // PubKey contains the public key corresponding to the private key for + // this address (stored in plaintext). + PubKey []byte -// GetPrivateKeyParams contains the parameters for retrieving a private key. -type GetPrivateKeyParams struct { - // WalletID is the ID of the wallet to query. - // - // NOTE: uint32 is used to ensure compatibility with standard SQL - // databases (signed 64-bit integers). - WalletID uint32 + // EncryptedPrivateKey contains the encrypted private key for the address. + EncryptedPrivateKey []byte - // Address is the address for which to retrieve the private key. - Address address.Address + // EncryptedScript contains the encrypted, pre serialized script. + // For P2SH and P2WSH this is the redeem or witness script. + // For Taproot this is the TLV encoded Tapscript. + EncryptedScript []byte } // GetAddressQuery contains the parameters for querying an address. @@ -667,8 +681,10 @@ type GetAddressQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // Address is the address to query. - Address address.Address + // ScriptPubKey is the script pubkey. If provided, the query will be + // performed using this value. Only applicable to imported addresses, + // as derived addresses have NULL script pubkeys. + ScriptPubKey []byte } // ListAddressesQuery contains the parameters for listing addresses. @@ -686,38 +702,6 @@ type ListAddressesQuery struct { Scope KeyScope } -// MarkAddressAsUsedParams contains the parameters for marking an address as -// used. -type MarkAddressAsUsedParams struct { - // WalletID is the ID of the wallet containing the address. - // - // NOTE: uint32 is used to ensure compatibility with standard SQL - // databases (signed 64-bit integers). - WalletID uint32 - - // Address is the address to mark as used. - Address address.Address -} - -// DerivationInfo contains the BIP-32 derivation path information for a key. -type DerivationInfo struct { - // KeyScope is the key scope of the derivation path. - KeyScope KeyScope - - // MasterKeyFingerprint is the fingerprint of the master key. - MasterKeyFingerprint uint32 - - // Account is the account number of the derivation path. - Account uint32 - - // Branch is the branch number of the derivation path (0 for external, - // 1 for internal). - Branch uint32 - - // Index is the index of the key in the branch. - Index uint32 -} - // -------------------- // TxStore Types // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 9019fc2ad6..1bc6793e01 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -4,8 +4,6 @@ import ( "context" "errors" - "github.com/btcsuite/btcd/address/v2" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" ) @@ -131,44 +129,53 @@ type AccountStore interface { RenameAccount(ctx context.Context, params RenameAccountParams) error } +// AddressDerivationFunc is called by the database layer after allocating an +// address index to derive the actual address data (script_pub_key). As the +// database should not know about how to derive an address, we pass this as a +// callback. +type AddressDerivationFunc func(ctx context.Context, accountID uint32, + branch uint32, index uint32) (*DerivedAddressData, error) + +// DerivedAddressData contains the derived address information returned by +// the AddressDerivationFunc callback. +type DerivedAddressData struct { + // ScriptPubKey is the script public key for the derived address. + ScriptPubKey []byte +} + // AddressStore defines the database actions for managing addresses. type AddressStore interface { - // NewAddress creates a new address for a given account and key scope. - // It returns the newly created address or an error if the creation - // fails. - NewAddress(ctx context.Context, params NewAddressParams) ( - address.Address, error) - - // ImportAddress imports a new address, script, or private key. If a - // private key is provided in the parameters, the address will be - // spendable. Otherwise, it will be imported as watch-only. It returns - // information about the imported address or an error if the import - // fails. - ImportAddress(ctx context.Context, params ImportAddressParams) ( - *AddressInfo, error) + // NewDerivedAddress creates a new HD-derived address for the specified + // account and key scope. The database layer allocates the address index + // atomically, then calls deriveFn to derive the actual address data. + // Returns the complete address metadata including the derived + // script_pub_key. + NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) + + // NewImportedAddress imports a new address, script, or private key. + // If a private key is provided in the parameters, the address will + // be spendable. Otherwise, it will be imported as watch-only. It + // returns information about the imported address or an error if the + // import fails. + NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) // GetAddress retrieves information about a specific address. It // returns an AddressInfo struct containing the address's properties or // an error if the address is not found. - GetAddress(ctx context.Context, query GetAddressQuery) ( - *AddressInfo, error) + GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) // ListAddresses returns a slice of AddressInfo for all addresses in a // given account. It returns an empty slice if no addresses are found. - ListAddresses(ctx context.Context, query ListAddressesQuery) ( - []AddressInfo, error) - - // MarkAddressAsUsed marks a given address as used. This is used to - // ensure that the address is not reused. - MarkAddressAsUsed(ctx context.Context, - params MarkAddressAsUsedParams) error + ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, + error) - // GetPrivateKey retrieves the private key for a given address. This - // method is ONLY valid for addresses that were imported with a private - // key. It will return an error for derived HD addresses and watch-only - // imports. - GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( - *btcec.PrivateKey, error) + // GetAddressSecret retrieves the encrypted secret material for a given + // address. Returns the AddressSecret containing encrypted private key + // and scripts, or an error if the secret does not exist. + GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, + error) // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. @@ -176,8 +183,7 @@ type AddressStore interface { // GetAddressType returns the AddressTypeInfo associated with the given // address type identifier. An error is returned if the type is unknown. - GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, - error) + GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) } // TxStore defines the database actions for managing transaction records. From c5d82b214fb8696bd4619bf3e0362bb5f35a0585 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 11 Jan 2026 20:11:53 -0300 Subject: [PATCH 069/695] wallet: add addresses SQL migrations --- .../postgres/000006_addresses.down.sql | 4 + .../postgres/000006_addresses.up.sql | 94 ++++++++++++++++++ .../sqlite/000006_addresses.down.sql | 4 + .../migrations/sqlite/000006_addresses.up.sql | 95 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 17 ++++ wallet/internal/db/sqlc/sqlite/models.go | 17 ++++ 6 files changed, 231 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000006_addresses.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000006_addresses.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000006_addresses.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql new file mode 100644 index 0000000000..92ce9f0dd2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS address_secrets; +DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql new file mode 100644 index 0000000000..639d62150d --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -0,0 +1,94 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Addresses table stores all addresses under each account. Addresses can be +-- either HD-derived (following BIP32/BIP44 derivation paths) or imported from +-- external sources (e.g., watch-only addresses, hardware wallet addresses). +-- +-- The table supports both address types through nullable derivation fields: +-- - HD-derived addresses have address_branch and address_index values +-- - Imported addresses have NULL derivation fields and store pub_key +CREATE TABLE addresses ( + -- DB ID of the address, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the account this address belongs to. + account_id BIGINT NOT NULL, + + -- Script pubkey that locks funds on-chain (stored in plaintext). + script_pub_key BYTEA NOT NULL, + + -- Reference to the address type (e.g., P2PKH, P2WPKH, P2TR). Determines + -- how the address is encoded and how funds can be spent. + type_id SMALLINT NOT NULL, + + -- Branch number in BIP44 derivation path (typically 0 for external, 1 for + -- internal/change). NULL for imported addresses. + address_branch BIGINT, + + -- Index number in BIP44 derivation path (sequential counter within each + -- branch). NULL for imported addresses. + address_index BIGINT, + + -- Public key for imported addresses (stored in plaintext). NULL for + -- HD-derived addresses since their public keys are derived from the + -- account key. + pub_key BYTEA, + + -- Timestamp when the address was created. Automatically set by the database. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if addresses still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + + -- Foreign key constraint to address types. Using ON DELETE RESTRICT to + -- ensure that the address type cannot be deleted if addresses still exist. + FOREIGN KEY (type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique partial index to prevent duplicate address derivations within the +-- same account. Only enforced when both branch and index are non-NULL +-- (HD-derived addresses). Imported addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_branch_index +ON addresses (account_id, address_branch, address_index) +WHERE address_branch IS NOT NULL +AND address_index IS NOT NULL; + +-- Unique partial index to prevent duplicate script_pub_key within the same +-- account. Only enforced when script_pub_key is non-NULL (imported addresses). +-- Derived addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key +ON addresses (account_id, script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Index on script_pub_key for efficient lookups by script pubkey. +-- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. +CREATE INDEX idx_addresses_script_pub_key +ON addresses (script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Address Secrets table stores sensitive encrypted material needed to spend +-- from an address. This table has a one-to-one relationship with addresses. +-- Watch-only addresses may have no row in this table. +CREATE TABLE address_secrets ( + -- Reference to the address these secrets belong to. + address_id BIGINT NOT NULL, + + -- Encrypted private key for imported addresses. NULL for HD-derived + -- addresses since their private keys are derived from the account key. + encrypted_priv_key BYTEA, + + -- Encrypted script for script-based addresses (P2SH, P2WSH). Contains the + -- redeem script or witness script needed to spend the output. + encrypted_script BYTEA, + + -- Foreign key constraint to addresses. Using ON DELETE RESTRICT to ensure + -- that the address cannot be deleted if secrets still exist. + FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between address and its +-- secrets. +CREATE UNIQUE INDEX uidx_address_secrets_address +ON address_secrets (address_id); diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql new file mode 100644 index 0000000000..92ce9f0dd2 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS address_secrets; +DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql new file mode 100644 index 0000000000..3d3b6e50a5 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -0,0 +1,95 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Addresses table stores all addresses under each account. Addresses can be +-- either HD-derived (following BIP32/BIP44 derivation paths) or imported from +-- external sources (e.g., watch-only addresses, hardware wallet addresses). +-- +-- The table supports both address types through nullable derivation fields: +-- - HD-derived addresses have address_branch and address_index values +-- - Imported addresses have NULL derivation fields and store pub_key +CREATE TABLE addresses ( + -- DB ID of the address, primary key. + id INTEGER PRIMARY KEY, + + -- Reference to the account this address belongs to. + account_id INTEGER NOT NULL, + + -- Script pubkey that locks funds on-chain (stored in plaintext). + script_pub_key BLOB NOT NULL, + + -- Reference to the address type (e.g., P2PKH, P2WPKH, P2TR). Determines + -- how the address is encoded and how funds can be spent. + type_id INTEGER NOT NULL, + + -- Branch number in BIP44 derivation path (typically 0 for external, 1 for + -- internal/change). NULL for imported addresses. + address_branch INTEGER, + + -- Index number in BIP44 derivation path (sequential counter within each + -- branch). NULL for imported addresses. + address_index INTEGER, + + -- Public key for imported addresses (stored in plaintext). NULL for + -- HD-derived addresses since their public keys are derived from the + -- account key. + pub_key BLOB, + + -- Timestamp when the address was created. Automatically set by the database. + created_at DATETIME NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if addresses still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + + -- Foreign key constraint to address types. Using ON DELETE RESTRICT to + -- ensure that the address type cannot be deleted if addresses still exist. + FOREIGN KEY (type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique partial index to prevent duplicate address derivations within the +-- same account. Only enforced when both branch and index are non-NULL +-- (HD-derived addresses). Imported addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_branch_index +ON addresses (account_id, address_branch, address_index) +WHERE + address_branch IS NOT NULL + AND address_index IS NOT NULL; + +-- Unique partial index to prevent duplicate script_pub_key within the same +-- account. Only enforced when script_pub_key is non-NULL (imported addresses). +-- Derived addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key +ON addresses (account_id, script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Index on script_pub_key for efficient lookups by script pubkey. +-- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. +CREATE INDEX idx_addresses_script_pub_key +ON addresses (script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Address Secrets table stores sensitive encrypted material needed to spend +-- from an address. This table has a one-to-one relationship with addresses. +-- Watch-only addresses may have no row in this table. +CREATE TABLE address_secrets ( + -- Reference to the address these secrets belong to. + address_id INTEGER NOT NULL, + + -- Encrypted private key for imported addresses. NULL for HD-derived + -- addresses since their private keys are derived from the account key. + encrypted_priv_key BLOB, + + -- Encrypted script for script-based addresses (P2SH, P2WSH). Contains the + -- redeem script or witness script needed to spend the output. + encrypted_script BLOB, + + -- Foreign key constraint to addresses. Using ON DELETE RESTRICT to ensure + -- that the address cannot be deleted if secrets still exist. + FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between address and its +-- secrets. +CREATE UNIQUE INDEX uidx_address_secrets_address +ON address_secrets (address_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 4c4bdc252c..31e2c52f9f 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -31,6 +31,23 @@ type AccountSecret struct { EncryptedPrivateKey []byte } +type Address struct { + ID int64 + AccountID int64 + ScriptPubKey []byte + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte + CreatedAt time.Time +} + +type AddressSecret struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + type AddressType struct { ID int16 Description string diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 9427d3abb3..215b2f1e7d 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -31,6 +31,23 @@ type AccountSecret struct { EncryptedPrivateKey []byte } +type Address struct { + ID int64 + AccountID int64 + ScriptPubKey []byte + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte + CreatedAt time.Time +} + +type AddressSecret struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + type AddressType struct { ID int64 Description string From 47b750bc30e10e7da235acc57a31874a7f2fc954 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 00:02:07 -0300 Subject: [PATCH 070/695] wallet: add addresses SQL queries --- .../db/queries/postgres/addresses.sql | 94 ++++++ .../internal/db/queries/sqlite/addresses.sql | 94 ++++++ .../db/sqlc/postgres/addresses.sql.go | 288 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 60 ++++ wallet/internal/db/sqlc/postgres/querier.go | 19 ++ .../internal/db/sqlc/sqlite/addresses.sql.go | 288 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 60 ++++ wallet/internal/db/sqlc/sqlite/querier.go | 19 ++ 8 files changed, 922 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/addresses.sql create mode 100644 wallet/internal/db/queries/sqlite/addresses.sql create mode 100644 wallet/internal/db/sqlc/postgres/addresses.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/addresses.sql.go diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql new file mode 100644 index 0000000000..710a2ed983 --- /dev/null +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -0,0 +1,94 @@ +-- name: InsertAddressSecret :exec +-- Inserts address secret information (private key, script) for imported addresses. +-- Not used for derived addresses (their keys are derived from account key). +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + $1, $2, $3 +); + +-- name: GetAddressByScriptPubKey :one +-- Retrieves an address by its script pubkey and account wallet. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = $1 AND ks.wallet_id = $2; + +-- name: GetAddressSecret :one +-- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +-- - Address exists with secret: returns full row +-- - Address exists without secret (watch-only/derived): returns row with NULL secret fields +-- - Address does not exist: returns no rows (sql.ErrNoRows) +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = $1; + +-- name: CreateDerivedAddress :one +-- Creates a derived address with the given index and derived data. +-- The index is allocated separately via GetAndIncrementNextAddressIndex. +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, created_at; + +-- name: CreateImportedAddress :one +-- Creates an imported address (no derivation path, has script/pubkey). +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + $1, $2, $3, NULL, NULL, $4 +) +RETURNING id, created_at; + +-- name: ListAddressesByAccount :many +-- Lists all addresses for a given account identified by wallet_id, key scope +-- (purpose/coin_type), and account name. Returns all address columns for +-- filtering and processing by the application. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + AND acc.account_name = $4; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql new file mode 100644 index 0000000000..81799b396a --- /dev/null +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -0,0 +1,94 @@ +-- name: InsertAddressSecret :exec +-- Inserts address secret information (private key, script) for imported addresses. +-- Not used for derived addresses (their keys are derived from account key). +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + ?, ?, ? +); + +-- name: GetAddressByScriptPubKey :one +-- Retrieves an address by its script pubkey and account wallet. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = ? AND ks.wallet_id = ?; + +-- name: GetAddressSecret :one +-- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +-- - Address exists with secret: returns full row +-- - Address exists without secret (watch-only/derived): returns row with NULL secret fields +-- - Address does not exist: returns no rows (sql.ErrNoRows) +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = ?; + +-- name: CreateDerivedAddress :one +-- Creates a derived address with the given index and derived data. +-- The index is allocated separately via GetAndIncrementNextAddressIndex. +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +RETURNING id, created_at; + +-- name: CreateImportedAddress :one +-- Creates an imported address (no derivation path, has script/pubkey). +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + ?1, ?2, ?3, NULL, NULL, ?4 +) +RETURNING id, created_at; + +-- name: ListAddressesByAccount :many +-- Lists all addresses for a given account identified by wallet_id, key scope +-- (purpose/coin_type), and account name. Returns all address columns for +-- filtering and processing by the application. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? + AND acc.account_name = ?; diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go new file mode 100644 index 0000000000..d282bfe308 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: addresses.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const CreateDerivedAddress = `-- name: CreateDerivedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, created_at +` + +type CreateDerivedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte +} + +type CreateDerivedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a derived address with the given index and derived data. +// The index is allocated separately via GetAndIncrementNextAddressIndex. +func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { + row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.AddressBranch, + arg.AddressIndex, + arg.PubKey, + ) + var i CreateDerivedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const CreateImportedAddress = `-- name: CreateImportedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + $1, $2, $3, NULL, NULL, $4 +) +RETURNING id, created_at +` + +type CreateImportedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int16 + PubKey []byte +} + +type CreateImportedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates an imported address (no derivation path, has script/pubkey). +func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { + row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.PubKey, + ) + var i CreateImportedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = $1 AND ks.wallet_id = $2 +` + +type GetAddressByScriptPubKeyParams struct { + ScriptPubKey []byte + WalletID int64 +} + +type GetAddressByScriptPubKeyRow struct { + ID int64 + AccountID int64 + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int16 + HasPrivateKey bool + HasScript bool +} + +// Retrieves an address by its script pubkey and account wallet. +func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) { + row := q.queryRow(ctx, q.getAddressByScriptPubKeyStmt, GetAddressByScriptPubKey, arg.ScriptPubKey, arg.WalletID) + var i GetAddressByScriptPubKeyRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ) + return i, err +} + +const GetAddressSecret = `-- name: GetAddressSecret :one +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = $1 +` + +type GetAddressSecretRow struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +// - Address exists with secret: returns full row +// - Address exists without secret (watch-only/derived): returns row with NULL secret fields +// - Address does not exist: returns no rows (sql.ErrNoRows) +func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) + var i GetAddressSecretRow + err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) + return i, err +} + +const InsertAddressSecret = `-- name: InsertAddressSecret :exec +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + $1, $2, $3 +) +` + +type InsertAddressSecretParams struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Inserts address secret information (private key, script) for imported addresses. +// Not used for derived addresses (their keys are derived from account key). +func (q *Queries) InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error { + _, err := q.exec(ctx, q.insertAddressSecretStmt, InsertAddressSecret, arg.AddressID, arg.EncryptedPrivKey, arg.EncryptedScript) + return err +} + +const ListAddressesByAccount = `-- name: ListAddressesByAccount :many +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + AND acc.account_name = $4 +` + +type ListAddressesByAccountParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type ListAddressesByAccountRow struct { + ID int64 + AccountID int64 + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int16 + HasPrivateKey bool + HasScript bool +} + +// Lists all addresses for a given account identified by wallet_id, key scope +// (purpose/coin_type), and account name. Returns all address columns for +// filtering and processing by the application. +func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { + rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAddressesByAccountRow + for rows.Next() { + var i ListAddressesByAccountRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 7f9d3d60c6..1534c7b7eb 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -33,9 +33,15 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) } + if q.createDerivedAddressStmt, err = db.PrepareContext(ctx, CreateDerivedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAddress: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } + if q.createImportedAddressStmt, err = db.PrepareContext(ctx, CreateImportedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAddress: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -66,6 +72,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) + } + if q.getAddressSecretStmt, err = db.PrepareContext(ctx, GetAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressSecret: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -90,6 +102,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } @@ -117,6 +132,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listAddressesByAccountStmt, err = db.PrepareContext(ctx, ListAddressesByAccount); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressesByAccount: %w", err) + } if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } @@ -158,11 +176,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) } } + if q.createDerivedAddressStmt != nil { + if cerr := q.createDerivedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAddressStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) } } + if q.createImportedAddressStmt != nil { + if cerr := q.createImportedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAddressStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -213,6 +241,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAddressByScriptPubKeyStmt != nil { + if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) + } + } + if q.getAddressSecretStmt != nil { + if cerr := q.getAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressSecretStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -253,6 +291,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.insertAddressSecretStmt != nil { + if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) @@ -298,6 +341,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listAddressesByAccountStmt != nil { + if cerr := q.listAddressesByAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressesByAccountStmt: %w", cerr) + } + } if q.listKeyScopesByWalletStmt != nil { if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) @@ -375,7 +423,9 @@ type Queries struct { createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt + createDerivedAddressStmt *sql.Stmt createImportedAccountStmt *sql.Stmt + createImportedAddressStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt @@ -386,6 +436,8 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAddressByScriptPubKeyStmt *sql.Stmt + getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt @@ -394,6 +446,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt @@ -403,6 +456,7 @@ type Queries struct { listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt listAddressTypesStmt *sql.Stmt + listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt lockAccountScopeStmt *sql.Stmt @@ -419,7 +473,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, + createDerivedAddressStmt: q.createDerivedAddressStmt, createImportedAccountStmt: q.createImportedAccountStmt, + createImportedAddressStmt: q.createImportedAddressStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, @@ -430,6 +486,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, + getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, @@ -438,6 +496,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, @@ -447,6 +506,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, listAddressTypesStmt: q.listAddressTypesStmt, + listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, lockAccountScopeStmt: q.lockAccountScopeStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 114ed39acf..c48f70db9e 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -19,10 +19,15 @@ type Querier interface { // Test-only: Creates a derived account with a specific account number. // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) + // Creates a derived address with the given index and derived data. + // The index is allocated separately via GetAndIncrementNextAddressIndex. + CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) + // Creates an imported address (no derivation path, has script/pubkey). + CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -41,6 +46,13 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Retrieves an address by its script pubkey and account wallet. + GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) + // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: + // - Address exists with secret: returns full row + // - Address exists without secret (watch-only/derived): returns row with NULL secret fields + // - Address does not exist: returns no rows (sql.ErrNoRows) + GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) @@ -53,6 +65,9 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Inserts address secret information (private key, script) for imported addresses. + // Not used for derived addresses (their keys are derived from account key). + InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error InsertBlock(ctx context.Context, arg InsertBlockParams) error // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. @@ -73,6 +88,10 @@ type Querier interface { ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all addresses for a given account identified by wallet_id, key scope + // (purpose/coin_type), and account name. Returns all address columns for + // filtering and processing by the application. + ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go new file mode 100644 index 0000000000..ebae0bb83f --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: addresses.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const CreateDerivedAddress = `-- name: CreateDerivedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +RETURNING id, created_at +` + +type CreateDerivedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte +} + +type CreateDerivedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a derived address with the given index and derived data. +// The index is allocated separately via GetAndIncrementNextAddressIndex. +func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { + row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.AddressBranch, + arg.AddressIndex, + arg.PubKey, + ) + var i CreateDerivedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const CreateImportedAddress = `-- name: CreateImportedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + ?1, ?2, ?3, NULL, NULL, ?4 +) +RETURNING id, created_at +` + +type CreateImportedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int64 + PubKey []byte +} + +type CreateImportedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates an imported address (no derivation path, has script/pubkey). +func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { + row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.PubKey, + ) + var i CreateImportedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = ? AND ks.wallet_id = ? +` + +type GetAddressByScriptPubKeyParams struct { + ScriptPubKey []byte + WalletID int64 +} + +type GetAddressByScriptPubKeyRow struct { + ID int64 + AccountID int64 + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int64 + HasPrivateKey bool + HasScript bool +} + +// Retrieves an address by its script pubkey and account wallet. +func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) { + row := q.queryRow(ctx, q.getAddressByScriptPubKeyStmt, GetAddressByScriptPubKey, arg.ScriptPubKey, arg.WalletID) + var i GetAddressByScriptPubKeyRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ) + return i, err +} + +const GetAddressSecret = `-- name: GetAddressSecret :one +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = ? +` + +type GetAddressSecretRow struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +// - Address exists with secret: returns full row +// - Address exists without secret (watch-only/derived): returns row with NULL secret fields +// - Address does not exist: returns no rows (sql.ErrNoRows) +func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) + var i GetAddressSecretRow + err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) + return i, err +} + +const InsertAddressSecret = `-- name: InsertAddressSecret :exec +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + ?, ?, ? +) +` + +type InsertAddressSecretParams struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Inserts address secret information (private key, script) for imported addresses. +// Not used for derived addresses (their keys are derived from account key). +func (q *Queries) InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error { + _, err := q.exec(ctx, q.insertAddressSecretStmt, InsertAddressSecret, arg.AddressID, arg.EncryptedPrivKey, arg.EncryptedScript) + return err +} + +const ListAddressesByAccount = `-- name: ListAddressesByAccount :many +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? + AND acc.account_name = ? +` + +type ListAddressesByAccountParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type ListAddressesByAccountRow struct { + ID int64 + AccountID int64 + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int64 + HasPrivateKey bool + HasScript bool +} + +// Lists all addresses for a given account identified by wallet_id, key scope +// (purpose/coin_type), and account name. Returns all address columns for +// filtering and processing by the application. +func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { + rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAddressesByAccountRow + for rows.Next() { + var i ListAddressesByAccountRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 2b4a10c8cd..bc5f723a86 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -33,9 +33,15 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) } + if q.createDerivedAddressStmt, err = db.PrepareContext(ctx, CreateDerivedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAddress: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } + if q.createImportedAddressStmt, err = db.PrepareContext(ctx, CreateImportedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAddress: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -66,6 +72,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) + } + if q.getAddressSecretStmt, err = db.PrepareContext(ctx, GetAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressSecret: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -90,6 +102,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } @@ -117,6 +132,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listAddressesByAccountStmt, err = db.PrepareContext(ctx, ListAddressesByAccount); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressesByAccount: %w", err) + } if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } @@ -155,11 +173,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) } } + if q.createDerivedAddressStmt != nil { + if cerr := q.createDerivedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAddressStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) } } + if q.createImportedAddressStmt != nil { + if cerr := q.createImportedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAddressStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -210,6 +238,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAddressByScriptPubKeyStmt != nil { + if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) + } + } + if q.getAddressSecretStmt != nil { + if cerr := q.getAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressSecretStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -250,6 +288,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.insertAddressSecretStmt != nil { + if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) @@ -295,6 +338,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listAddressesByAccountStmt != nil { + if cerr := q.listAddressesByAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressesByAccountStmt: %w", cerr) + } + } if q.listKeyScopesByWalletStmt != nil { if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) @@ -367,7 +415,9 @@ type Queries struct { createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt + createDerivedAddressStmt *sql.Stmt createImportedAccountStmt *sql.Stmt + createImportedAddressStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt @@ -378,6 +428,8 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAddressByScriptPubKeyStmt *sql.Stmt + getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt @@ -386,6 +438,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt @@ -395,6 +448,7 @@ type Queries struct { listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt listAddressTypesStmt *sql.Stmt + listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt @@ -410,7 +464,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, + createDerivedAddressStmt: q.createDerivedAddressStmt, createImportedAccountStmt: q.createImportedAccountStmt, + createImportedAddressStmt: q.createImportedAddressStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, @@ -421,6 +477,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, + getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, @@ -429,6 +487,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, @@ -438,6 +497,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, listAddressTypesStmt: q.listAddressTypesStmt, + listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 742741b180..cf8690a7a8 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -18,10 +18,15 @@ type Querier interface { // Test-only: Creates a derived account with a specific account number. // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) + // Creates a derived address with the given index and derived data. + // The index is allocated separately via GetAndIncrementNextAddressIndex. + CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) + // Creates an imported address (no derivation path, has script/pubkey). + CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -40,6 +45,13 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Retrieves an address by its script pubkey and account wallet. + GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) + // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: + // - Address exists with secret: returns full row + // - Address exists without secret (watch-only/derived): returns row with NULL secret fields + // - Address does not exist: returns no rows (sql.ErrNoRows) + GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) @@ -52,6 +64,9 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Inserts address secret information (private key, script) for imported addresses. + // Not used for derived addresses (their keys are derived from account key). + InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error InsertBlock(ctx context.Context, arg InsertBlockParams) error // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. @@ -72,6 +87,10 @@ type Querier interface { ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all addresses for a given account identified by wallet_id, key scope + // (purpose/coin_type), and account name. Returns all address columns for + // filtering and processing by the application. + ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) From 1ebed296b23611e2d5388c16c137eebb8c6f4213 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 13:16:03 -0300 Subject: [PATCH 071/695] wallet: return id in accounts SQL queries --- .../internal/db/queries/postgres/accounts.sql | 8 +++++++ .../internal/db/queries/sqlite/accounts.sql | 8 +++++++ .../internal/db/sqlc/postgres/accounts.sql.go | 24 +++++++++++++++++++ .../internal/db/sqlc/sqlite/accounts.sql.go | 24 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 9538e355f8..1d3b35ed32 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -78,6 +78,7 @@ INSERT INTO account_secrets ( -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -92,6 +93,7 @@ WHERE a.scope_id = $1 AND a.account_name = $2; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -106,6 +108,7 @@ WHERE a.scope_id = $1 AND a.account_number = $2; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -124,6 +127,7 @@ WHERE -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -161,6 +165,7 @@ WHERE a.id = $1; -- Lists all accounts in a scope, ordered by account number. Imported accounts -- (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -177,6 +182,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet and scope tuple, ordered by account number. -- Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -196,6 +202,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet filtered by account name, ordered by account -- number. Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -212,6 +219,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet, ordered by account number. Imported -- accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index f325be0520..e76c7c4cb8 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -49,6 +49,7 @@ INSERT INTO account_secrets ( -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -63,6 +64,7 @@ WHERE a.scope_id = ? AND a.account_name = ?; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -77,6 +79,7 @@ WHERE a.scope_id = ? AND a.account_number = ?; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -95,6 +98,7 @@ WHERE -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -132,6 +136,7 @@ WHERE a.id = ?; -- Lists all accounts in a scope, ordered by account number. Imported accounts -- (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -148,6 +153,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet and scope tuple, ordered by account number. -- Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -167,6 +173,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet filtered by account name, ordered by account -- number. Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -183,6 +190,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet, ordered by account number. Imported -- accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index 0d2e667312..b208788cdb 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -174,6 +174,7 @@ func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedA const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -192,6 +193,7 @@ type GetAccountByScopeAndNameParams struct { } type GetAccountByScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -206,6 +208,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) var i GetAccountByScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -219,6 +222,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -237,6 +241,7 @@ type GetAccountByScopeAndNumberParams struct { } type GetAccountByScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -251,6 +256,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) var i GetAccountByScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -264,6 +270,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -288,6 +295,7 @@ type GetAccountByWalletScopeAndNameParams struct { } type GetAccountByWalletScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -307,6 +315,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc ) var i GetAccountByWalletScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -320,6 +329,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -344,6 +354,7 @@ type GetAccountByWalletScopeAndNumberParams struct { } type GetAccountByWalletScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -363,6 +374,7 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA ) var i GetAccountByWalletScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -428,6 +440,7 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -442,6 +455,7 @@ ORDER BY a.account_number NULLS LAST ` type ListAccountsByScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -463,6 +477,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis for rows.Next() { var i ListAccountsByScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -486,6 +501,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis const ListAccountsByWallet = `-- name: ListAccountsByWallet :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -500,6 +516,7 @@ ORDER BY a.account_number NULLS LAST ` type ListAccountsByWalletRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -521,6 +538,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L for rows.Next() { var i ListAccountsByWalletRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -544,6 +562,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -563,6 +582,7 @@ type ListAccountsByWalletAndNameParams struct { } type ListAccountsByWalletAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -584,6 +604,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou for rows.Next() { var i ListAccountsByWalletAndNameRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -607,6 +628,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -630,6 +652,7 @@ type ListAccountsByWalletScopeParams struct { } type ListAccountsByWalletScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -651,6 +674,7 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount for rows.Next() { var i ListAccountsByWalletScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 90a939d402..b0f2631db9 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -172,6 +172,7 @@ func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedA const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -190,6 +191,7 @@ type GetAccountByScopeAndNameParams struct { } type GetAccountByScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -204,6 +206,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) var i GetAccountByScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -217,6 +220,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -235,6 +239,7 @@ type GetAccountByScopeAndNumberParams struct { } type GetAccountByScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -249,6 +254,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) var i GetAccountByScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -262,6 +268,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -286,6 +293,7 @@ type GetAccountByWalletScopeAndNameParams struct { } type GetAccountByWalletScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -305,6 +313,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc ) var i GetAccountByWalletScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -318,6 +327,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -342,6 +352,7 @@ type GetAccountByWalletScopeAndNumberParams struct { } type GetAccountByWalletScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -361,6 +372,7 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA ) var i GetAccountByWalletScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -426,6 +438,7 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -440,6 +453,7 @@ ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -461,6 +475,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis for rows.Next() { var i ListAccountsByScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -484,6 +499,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis const ListAccountsByWallet = `-- name: ListAccountsByWallet :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -498,6 +514,7 @@ ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByWalletRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -519,6 +536,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L for rows.Next() { var i ListAccountsByWalletRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -542,6 +560,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -561,6 +580,7 @@ type ListAccountsByWalletAndNameParams struct { } type ListAccountsByWalletAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -582,6 +602,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou for rows.Next() { var i ListAccountsByWalletAndNameRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -605,6 +626,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -628,6 +650,7 @@ type ListAccountsByWalletScopeParams struct { } type ListAccountsByWalletScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -649,6 +672,7 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount for rows.Next() { var i ListAccountsByWalletScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, From 7de312495bb00ed08784ad4ce928c9bff2c83baa Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 5 Feb 2026 16:12:53 -0300 Subject: [PATCH 072/695] wallet: add accounts next address index --- .../postgres/000005_accounts.up.sql | 6 ++ .../migrations/sqlite/000005_accounts.up.sql | 6 ++ .../internal/db/queries/postgres/accounts.sql | 30 ++++++++ .../internal/db/queries/sqlite/accounts.sql | 30 ++++++++ .../internal/db/sqlc/postgres/accounts.sql.go | 68 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 40 +++++++++++ wallet/internal/db/sqlc/postgres/models.go | 2 + wallet/internal/db/sqlc/postgres/querier.go | 12 ++++ .../internal/db/sqlc/sqlite/accounts.sql.go | 68 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 40 +++++++++++ wallet/internal/db/sqlc/sqlite/models.go | 2 + wallet/internal/db/sqlc/sqlite/querier.go | 12 ++++ 12 files changed, 316 insertions(+) diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index c51ea1f95e..cfe297ce26 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -64,6 +64,12 @@ CREATE TABLE accounts ( -- Timestamp when the account was created. Automatically set by the database. created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Next index to use for external addresses (branch 0) + next_external_index BIGINT NOT NULL DEFAULT 0, + + -- Next index to use for internal/change addresses (branch 1) + next_internal_index BIGINT NOT NULL DEFAULT 0, + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 0644de5ae4..399104ce8f 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -64,6 +64,12 @@ CREATE TABLE accounts ( -- Timestamp when the account was created. Automatically set by the database. created_at DATETIME NOT NULL DEFAULT current_timestamp, + -- Next index to use for external addresses (branch 0) + next_external_index INTEGER NOT NULL DEFAULT 0, + + -- Next index to use for internal/change addresses (branch 1) + next_internal_index INTEGER NOT NULL DEFAULT 0, + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 1d3b35ed32..9da524fa30 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -274,3 +274,33 @@ INSERT INTO accounts ( ) VALUES ($1, $2, $3, $4, $5) RETURNING id, account_number, created_at; + +-- name: GetAndIncrementNextExternalIndex :one +-- Atomically gets the next external address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = $1 +RETURNING (next_external_index - 1)::BIGINT AS address_index; + +-- name: GetAndIncrementNextInternalIndex :one +-- Atomically gets the next internal/change address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = $1 +RETURNING (next_internal_index - 1)::BIGINT AS address_index; + +-- name: UpdateAccountNextExternalIndex :exec +-- Updates the next_external_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_external_index = $2 +WHERE id = $1; + +-- name: UpdateAccountNextInternalIndex :exec +-- Updates the next_internal_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_internal_index = $2 +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index e76c7c4cb8..f6bfeb5f10 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -245,3 +245,33 @@ INSERT INTO accounts ( ) VALUES (?, ?, ?, ?, ?) RETURNING id, account_number, created_at; + +-- name: GetAndIncrementNextExternalIndex :one +-- Atomically gets the next external address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = ? +RETURNING next_external_index - 1 AS address_index; + +-- name: GetAndIncrementNextInternalIndex :one +-- Atomically gets the next internal/change address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = ? +RETURNING next_internal_index - 1 AS address_index; + +-- name: UpdateAccountNextExternalIndex :exec +-- Updates the next_external_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_external_index = ?2 +WHERE id = ?1; + +-- name: UpdateAccountNextInternalIndex :exec +-- Updates the next_internal_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_internal_index = ?2 +WHERE id = ?1; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b208788cdb..e6998c7c81 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -438,6 +438,38 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = $1 +RETURNING (next_external_index - 1)::BIGINT AS address_index +` + +// Atomically gets the next external address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextExternalIndexStmt, GetAndIncrementNextExternalIndex, id) + var address_index int64 + err := row.Scan(&address_index) + return address_index, err +} + +const GetAndIncrementNextInternalIndex = `-- name: GetAndIncrementNextInternalIndex :one +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = $1 +RETURNING (next_internal_index - 1)::BIGINT AS address_index +` + +// Atomically gets the next internal/change address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextInternalIndexStmt, GetAndIncrementNextInternalIndex, id) + var address_index int64 + err := row.Scan(&address_index) + return address_index, err +} + const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT a.id, @@ -803,3 +835,39 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } + +const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec +UPDATE accounts +SET next_external_index = $2 +WHERE id = $1 +` + +type UpdateAccountNextExternalIndexParams struct { + ID int64 + NextExternalIndex int64 +} + +// Updates the next_external_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) + return err +} + +const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec +UPDATE accounts +SET next_internal_index = $2 +WHERE id = $1 +` + +type UpdateAccountNextInternalIndexParams struct { + ID int64 + NextInternalIndex int64 +} + +// Updates the next_internal_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) + return err +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 1534c7b7eb..07cbfc7f13 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -81,6 +81,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } + if q.getAndIncrementNextExternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextExternalIndex: %w", err) + } + if q.getAndIncrementNextInternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextInternalIndex: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -150,6 +156,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) + } + if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -256,6 +268,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) } } + if q.getAndIncrementNextExternalIndexStmt != nil { + if cerr := q.getAndIncrementNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextExternalIndexStmt: %w", cerr) + } + } + if q.getAndIncrementNextInternalIndexStmt != nil { + if cerr := q.getAndIncrementNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextInternalIndexStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -371,6 +393,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateAccountNextExternalIndexStmt != nil { + if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) + } + } + if q.updateAccountNextInternalIndexStmt != nil { + if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -439,6 +471,8 @@ type Queries struct { getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt + getAndIncrementNextExternalIndexStmt *sql.Stmt + getAndIncrementNextInternalIndexStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt @@ -462,6 +496,8 @@ type Queries struct { lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateAccountNextExternalIndexStmt *sql.Stmt + updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -489,6 +525,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getAndIncrementNextExternalIndexStmt: q.getAndIncrementNextExternalIndexStmt, + getAndIncrementNextInternalIndexStmt: q.getAndIncrementNextInternalIndexStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, @@ -512,6 +550,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, + updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 31e2c52f9f..3c11ebf44b 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -19,6 +19,8 @@ type Account struct { MasterFingerprint sql.NullInt64 EncryptedPublicKey []byte CreatedAt time.Time + NextExternalIndex int64 + NextInternalIndex int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index c48f70db9e..54f20c5b51 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -55,6 +55,12 @@ type Querier interface { GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) + // Atomically gets the next external address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) + // Atomically gets the next internal/change address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) @@ -123,6 +129,12 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates the next_external_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error + // Updates the next_internal_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index b0f2631db9..3eacdc0306 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -436,6 +436,38 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = ? +RETURNING next_external_index - 1 AS address_index +` + +// Atomically gets the next external address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextExternalIndexStmt, GetAndIncrementNextExternalIndex, id) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const GetAndIncrementNextInternalIndex = `-- name: GetAndIncrementNextInternalIndex :one +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = ? +RETURNING next_internal_index - 1 AS address_index +` + +// Atomically gets the next internal/change address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextInternalIndexStmt, GetAndIncrementNextInternalIndex, id) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT a.id, @@ -769,3 +801,39 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } + +const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec +UPDATE accounts +SET next_external_index = ?2 +WHERE id = ?1 +` + +type UpdateAccountNextExternalIndexParams struct { + ID int64 + NextExternalIndex int64 +} + +// Updates the next_external_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) + return err +} + +const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec +UPDATE accounts +SET next_internal_index = ?2 +WHERE id = ?1 +` + +type UpdateAccountNextInternalIndexParams struct { + ID int64 + NextInternalIndex int64 +} + +// Updates the next_internal_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) + return err +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index bc5f723a86..25ad7c5f16 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -81,6 +81,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } + if q.getAndIncrementNextExternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextExternalIndex: %w", err) + } + if q.getAndIncrementNextInternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextInternalIndex: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -147,6 +153,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) + } + if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -253,6 +265,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) } } + if q.getAndIncrementNextExternalIndexStmt != nil { + if cerr := q.getAndIncrementNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextExternalIndexStmt: %w", cerr) + } + } + if q.getAndIncrementNextInternalIndexStmt != nil { + if cerr := q.getAndIncrementNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextInternalIndexStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -363,6 +385,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateAccountNextExternalIndexStmt != nil { + if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) + } + } + if q.updateAccountNextInternalIndexStmt != nil { + if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -431,6 +463,8 @@ type Queries struct { getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt + getAndIncrementNextExternalIndexStmt *sql.Stmt + getAndIncrementNextInternalIndexStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt @@ -453,6 +487,8 @@ type Queries struct { listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateAccountNextExternalIndexStmt *sql.Stmt + updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -480,6 +516,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getAndIncrementNextExternalIndexStmt: q.getAndIncrementNextExternalIndexStmt, + getAndIncrementNextInternalIndexStmt: q.getAndIncrementNextInternalIndexStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, @@ -502,6 +540,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, + updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 215b2f1e7d..8681080041 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -19,6 +19,8 @@ type Account struct { MasterFingerprint sql.NullInt64 EncryptedPublicKey []byte CreatedAt time.Time + NextExternalIndex int64 + NextInternalIndex int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index cf8690a7a8..3eba8e332e 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -54,6 +54,12 @@ type Querier interface { GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) + // Atomically gets the next external address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) + // Atomically gets the next internal/change address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) @@ -98,6 +104,12 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates the next_external_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error + // Updates the next_internal_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From 9d1bfa584923ed8c349ac8745168d4a8e17a3b09 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 24 Jan 2026 15:23:17 -0300 Subject: [PATCH 073/695] wallet: add new conversions to safecasting --- wallet/internal/db/safecasting.go | 11 ++++++ wallet/internal/db/safecasting_test.go | 51 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 9ffbea2f2b..c45c8a0588 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -43,6 +43,17 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } +// int64ToInt16 safely casts an int64 to an int16, returning an error +// if the value is out of range. +func int64ToInt16(v int64) (int16, error) { + if v < math.MinInt16 || v > math.MaxInt16 { + return 0, fmt.Errorf("could not cast %d to int16: %w", v, + ErrCastingOverflow) + } + + return int16(v), nil +} + // int64ToUint8 safely casts an int64 to an uint8, returning an error // if the value is out of range. func int64ToUint8(v int64) (uint8, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index ec4b4d748a..83e568af3a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -200,6 +200,57 @@ func TestInt16ToUint8(t *testing.T) { } } +// TestInt64ToInt16 checks that an int64 value is converted to int16 only +// when it fits within the signed 16 bit range. It should fail loudly for +// any value outside those limits. +func TestInt64ToInt16(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want int16 + wantErr bool + }{ + { + name: "min int16", + val: int64(math.MinInt16), + want: math.MinInt16, + }, + { + name: "max int16", + val: int64(math.MaxInt16), + want: math.MaxInt16, + }, + {name: "zero", val: 0, want: 0}, + { + name: "below min", + val: int64(math.MinInt16) - 1, + wantErr: true, + }, + { + name: "above max", + val: int64(math.MaxInt16) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToInt16(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From 06ca950d2b8b32d8f7ff4a76bf8cd46e66433ccc Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 24 Jan 2026 19:20:31 -0300 Subject: [PATCH 074/695] wallet: format safecasting --- wallet/internal/db/safecasting.go | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index c45c8a0588..8f40e43c69 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -21,10 +21,8 @@ var ( // if the value is out of range. func int64ToUint32(v int64) (uint32, error) { if v < 0 || v > math.MaxUint32 { - return 0, fmt.Errorf( - "could not cast %d to uint32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to uint32: %w", v, + ErrCastingOverflow) } return uint32(v), nil @@ -34,10 +32,8 @@ func int64ToUint32(v int64) (uint32, error) { // if the value is out of range. func int64ToInt32(v int64) (int32, error) { if v < math.MinInt32 || v > math.MaxInt32 { - return 0, fmt.Errorf( - "could not cast %d to int32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to int32: %w", v, + ErrCastingOverflow) } return int32(v), nil @@ -80,10 +76,8 @@ func int16ToUint8(v int16) (uint8, error) { // if the value is out of range. func uint32ToInt32(v uint32) (int32, error) { if v > math.MaxInt32 { - return 0, fmt.Errorf( - "could not cast %d to int32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to int32: %w", v, + ErrCastingOverflow) } return int32(v), nil @@ -104,17 +98,13 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { // an error if the value is out of range or invalid. func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { if !n.Valid { - return 0, fmt.Errorf( - "could not cast invalid NullInt32 to uint32: %w", - ErrInvalidNullInt, - ) + return 0, fmt.Errorf("could not cast invalid NullInt32 to uint32: %w", + ErrInvalidNullInt) } if n.Int32 < 0 { - return 0, fmt.Errorf( - "could not cast %d to uint32: %w", - n.Int32, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to uint32: %w", n.Int32, + ErrCastingOverflow) } return uint32(n.Int32), nil From afc9b3b684b28e33d073029759fda9dbbb98543f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 00:05:16 -0300 Subject: [PATCH 075/695] wallet: add AddressStore implementation --- wallet/internal/db/addresses_common.go | 633 +++++++++++++++++++++++++ wallet/internal/db/addresses_pg.go | 304 ++++++++++++ wallet/internal/db/addresses_sqlite.go | 308 ++++++++++++ wallet/internal/db/interface.go | 17 + 4 files changed, 1262 insertions(+) create mode 100644 wallet/internal/db/addresses_common.go create mode 100644 wallet/internal/db/addresses_pg.go create mode 100644 wallet/internal/db/addresses_sqlite.go diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go new file mode 100644 index 0000000000..9cb9884f1f --- /dev/null +++ b/wallet/internal/db/addresses_common.go @@ -0,0 +1,633 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "time" +) + +// DefaultImportedAccountName is the default account name for imported +// addresses. +const DefaultImportedAccountName = "imported" + +var ( + // errInvalidOriginID is returned when an origin ID from the database is + // outside the valid range [DerivedAccount, ImportedAccount]. In practice, + // this should never happen, but it's possible if the database is modified + // incorrectly or the query is incorrect. + errInvalidOriginID = errors.New("invalid origin ID: must be 0 or 1") + + // errInvalidDerivationPath is returned when the database contains an + // invalid derivation path, such as a missing index or branch for a + // derived address. This should never happen, but it's possible if the + // database is modified incorrectly or the query is incorrect. + errInvalidDerivationPath = errors.New("invalid derivation path") +) + +// accountLookupKey contains the fields needed to look up an account. +type accountLookupKey struct { + walletID int64 + purpose int64 + coinType int64 + accountName string +} + +// accountKeyFromParams extracts account lookup fields from params. +func accountKeyFromParams(params NewDerivedAddressParams) accountLookupKey { + return accountLookupKey{ + walletID: int64(params.WalletID), + purpose: int64(params.Scope.Purpose), + coinType: int64(params.Scope.Coin), + accountName: params.AccountName, + } +} + +// accountKeyFromImportedParams extracts account lookup fields from imported +// params using DefaultImportedAccountName. +func accountKeyFromImportedParams( + params NewImportedAddressParams) accountLookupKey { + + return accountLookupKey{ + walletID: int64(params.WalletID), + purpose: int64(params.Scope.Purpose), + coinType: int64(params.Scope.Coin), + accountName: DefaultImportedAccountName, + } +} + +// getAddressSecret is a generic helper that retrieves address secret +// information using the provided getter function and converts it to an +// AddressSecret with error handling. +func getAddressSecret[Row any](ctx context.Context, + getter func(context.Context, int64) (Row, error), addressID uint32, + toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { + + row, err := getter(ctx, int64(addressID)) + if err == nil { + return toSecret(row) + } + + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("address secret for address %d: %w", + addressID, ErrAddressNotFound) + } + + return nil, fmt.Errorf("get address secret: %w", err) +} + +// validate validates the required fields for creating an imported address. +// Returns sentinel errors on failure. +func (p NewImportedAddressParams) validate() error { + if len(p.ScriptPubKey) == 0 { + return ErrMissingScriptPubKey + } + + return nil +} + +// isWatchOnly returns true if the params include neither a private key nor +// a redeem or witness script. +func (p NewImportedAddressParams) isWatchOnly() bool { + noPrivKey := len(p.EncryptedPrivateKey) == 0 + noScript := len(p.EncryptedScript) == 0 + + if noPrivKey && noScript { + return true + } + + return false +} + +// idToOrigin safely converts an integer to AccountOrigin. It returns an error +// if the value is outside [DerivedAccount, ImportedAccount]. +func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { + if v < 0 || v > T(ImportedAccount) { + return 0, fmt.Errorf("address origin: %d: %w", v, errInvalidOriginID) + } + + return AccountOrigin(uint8(v)), nil +} + +// addressInfoRow captures common fields from all address row types across +// PostgreSQL and SQLite backends. Uses generic type parameters to handle +// different ID types (int16 for PostgreSQL, int64 for SQLite). +type addressInfoRow[TypeID, OriginIDType any] struct { + // ID is the database unique identifier for the address. + ID int64 + + // AccountID is the database unique identifier for the account. + AccountID int64 + + // TypeID is the database identifier for the address type. + TypeID TypeID + + // OriginID is the database identifier for address origin (derived=0, + // imported=1). + OriginID OriginIDType + + // HasPrivateKey indicates whether the address has an encrypted private key. + HasPrivateKey bool + + // HasScript indicates whether the address has an encrypted script. + HasScript bool + + // CreatedAt is when the address was created in the wallet database. + CreatedAt time.Time + + // AddressBranch is the BIP44 branch number (0=external, 1=internal/change), + // or NULL for imported addresses. + AddressBranch sql.NullInt64 + + // AddressIndex is the BIP44 index within the branch, or NULL for imported + // addresses. + AddressIndex sql.NullInt64 + + // ScriptPubKey is the script pubkey. Zero value for derived addresses. + ScriptPubKey []byte + + // PubKey is the public key. Zero value for derived addresses. + PubKey []byte + + // IDToAddrType converts TypeID to AddressType with validation. + IDToAddrType func(TypeID) (AddressType, error) + + // IDToOrigin converts OriginIDType to AccountOrigin with validation. + IDToOrigin func(OriginIDType) (AccountOrigin, error) +} + +// convertAddressIDs converts database IDs to their respective uint32 values +// with error handling. +func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { + addrID, err := int64ToUint32(id) + if err != nil { + return 0, 0, fmt.Errorf("address ID: %w", err) + } + + acctID, err := int64ToUint32(accountID) + if err != nil { + return 0, 0, fmt.Errorf("account ID: %w", err) + } + + return addrID, acctID, nil +} + +// newImportedAddressTx handles the shared transaction flow for creating an +// imported address across database backends. +func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( + ctx context.Context, create func(context.Context, CreateArgs) (Row, error), + createArgs CreateArgs, + insertFn func(QTX) func(context.Context, InsertArgs) error, qtx QTX, + insertArgs func(int64, NewImportedAddressParams) InsertArgs, + params NewImportedAddressParams, accountID int64, + rowID func(Row) int64, rowCreatedAt func(Row) time.Time) (*AddressInfo, + error) { + + addrRow, err := create(ctx, createArgs) + if err != nil { + return nil, fmt.Errorf("create imported address: %w", err) + } + + addrID := rowID(addrRow) + if !params.isWatchOnly() { + err = insertFn(qtx)(ctx, insertArgs(addrID, params)) + if err != nil { + return nil, fmt.Errorf("insert address secret: %w", err) + } + } + + id, acctID, err := convertAddressIDs(addrID, accountID) + if err != nil { + return nil, err + } + + return &AddressInfo{ + ID: id, + AccountID: acctID, + AddrType: params.AddressType, + CreatedAt: rowCreatedAt(addrRow), + Origin: ImportedAccount, + ScriptPubKey: params.ScriptPubKey, + PubKey: params.PubKey, + IsWatchOnly: params.isWatchOnly(), + }, nil +} + +// convertAddressMetadata converts address type and origin IDs with error +// handling. +func convertAddressMetadata[TypeID, OriginIDType any]( + row addressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, + error) { + + addrType, err := row.IDToAddrType(row.TypeID) + if err != nil { + return 0, 0, fmt.Errorf("address type: %w", err) + } + + origin, err := row.IDToOrigin(row.OriginID) + if err != nil { + return 0, 0, fmt.Errorf("address origin: %w", err) + } + + return addrType, origin, nil +} + +// convertAddressPath converts BIP44 branch and index values with error +// handling. +func convertAddressPath(branch, index sql.NullInt64) (uint32, uint32, error) { + addrBranch, err := int64ToUint32(branch.Int64) + if err != nil { + return 0, 0, fmt.Errorf("address branch: %w", err) + } + + addrIndex, err := int64ToUint32(index.Int64) + if err != nil { + return 0, 0, fmt.Errorf("address index: %w", err) + } + + return addrBranch, addrIndex, nil +} + +// addressRowToInfo converts raw database field values into an AddressInfo +// struct. It handles type conversion and validation for each field. +func addressRowToInfo[TypeID, OriginIDType any]( + row addressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { + + id, accountID, err := convertAddressIDs(row.ID, row.AccountID) + if err != nil { + return nil, err + } + + addrType, origin, err := convertAddressMetadata(row) + if err != nil { + return nil, err + } + + if origin == DerivedAccount { + if !row.AddressIndex.Valid || + !row.AddressBranch.Valid { + + return nil, errInvalidDerivationPath + } + } + + addrBranch, addrIndex, err := convertAddressPath( + row.AddressBranch, row.AddressIndex, + ) + if err != nil { + return nil, err + } + + isWatchOnly := origin == ImportedAccount && !row.HasPrivateKey && + !row.HasScript + + return &AddressInfo{ + ID: id, + AccountID: accountID, + AddrType: addrType, + CreatedAt: row.CreatedAt, + Origin: origin, + Branch: addrBranch, + Index: addrIndex, + ScriptPubKey: row.ScriptPubKey, + PubKey: row.PubKey, + IsWatchOnly: isWatchOnly, + }, nil +} + +// getAddress is a generic helper that retrieves a single address using the +// provided getter function. It handles sql.ErrNoRows mapping and delegates +// conversion to the toInfo function. +func getAddress[T any, Args any](ctx context.Context, + getter func(context.Context, Args) (T, error), args Args, + toInfo func(T) (*AddressInfo, error)) (*AddressInfo, error) { + + row, err := getter(ctx, args) + if err == nil { + return toInfo(row) + } + + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get address: %w", err) + } + + return nil, ErrAddressNotFound +} + +// listAddresses is a generic helper that retrieves addresses using the provided +// lister function and converts the results to AddressInfo structs. +func listAddresses[T any, Args any](ctx context.Context, + lister func(context.Context, Args) ([]T, error), args Args, + toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { + + rows, err := lister(ctx, args) + if err != nil { + return nil, fmt.Errorf("list addresses: %w", err) + } + + return addressInfosFromRows(rows, toInfo) +} + +// addressInfosFromRows converts a slice of row types to AddressInfo structs +// using the provided converter function. +func addressInfosFromRows[T any](rows []T, + toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { + + infos := make([]AddressInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// derivedAddressAdapters groups the functions needed to create a +// derived address across different database backends. +type derivedAddressAdapters[QTX any, AccountRow any, AccountParams any, + AddrRow any] struct { + + // getAccount retrieves an account by the provided parameters. + getAccount func(context.Context, AccountParams) (AccountRow, error) + + // accountParams converts params to account lookup parameters. + accountParams func(NewDerivedAddressParams) AccountParams + + // getAccountID extracts the account ID from an account row. + getAccountID func(AccountRow) int64 + + // getExtIndex returns a function to get the external index. + getExtIndex func(QTX) func(context.Context, int64) (int64, error) + + // getIntIndex returns a function to get the internal index. + getIntIndex func(QTX) func(context.Context, int64) (int64, error) + + // createAddr returns a function to create an address row. + createAddr func(QTX) func(context.Context, int64, AddressType, uint32, + uint32, []byte) (AddrRow, error) + + // rowID extracts the ID from an address row. + rowID func(AddrRow) int64 + + // rowCreatedAt extracts the creation time from an address row. + rowCreatedAt func(AddrRow) time.Time +} + +// importedAddressAdapters groups the functions needed to create an +// imported address across different database backends. +type importedAddressAdapters[QTX any, AccountRow any, + AccountParams any, CreateArgs any, AddrRow any, + SecretParams any] struct { + + // getAccount retrieves an account by the provided parameters. + getAccount func(context.Context, AccountParams) (AccountRow, error) + + // accountParams converts params to account lookup parameters. + accountParams func(NewImportedAddressParams) AccountParams + + // getAccountID extracts the account ID from an account row. + getAccountID func(AccountRow) int64 + + // createAddr returns a function to create an address row. + createAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) + + // createParams converts accountID and params to address creation + // parameters. + createParams func(int64, NewImportedAddressParams) CreateArgs + + // insertSecret returns a function to insert address secret. + insertSecret func(QTX) func(context.Context, SecretParams) error + + // secretParams converts address ID and params to secret parameters. + secretParams func(int64, NewImportedAddressParams) SecretParams + + // rowID extracts the ID from an address row. + rowID func(AddrRow) int64 + + // rowCreatedAt extracts the creation time from an address row. + rowCreatedAt func(AddrRow) time.Time +} + +// getAddressFunc defines a function signature for retrieving a single address. +type getAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) + +// getAddressByQuery validates the query and executes the script-based lookup. +func getAddressByQuery(ctx context.Context, query GetAddressQuery, + getter getAddressFunc) (*AddressInfo, error) { + + if len(query.ScriptPubKey) == 0 { + return nil, ErrInvalidAddressQuery + } + + return getter(ctx, query) +} + +// createDerivedAddress is a generic helper that encapsulates the shared +// derived address creation logic. It calls derivedAddressInput to prepare +// inputs and then createFn to create the address. +func createDerivedAddress[T any](ctx context.Context, + params NewDerivedAddressParams, accountID int64, + getExtIndex func(context.Context, int64) (int64, error), + getIntIndex func(context.Context, int64) (int64, error), + createFn func(context.Context, int64, AddressType, uint32, uint32, + []byte) (T, error), + rowID func(T) int64, rowCreatedAt func(T) time.Time, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + addrType, branch, index, scriptPubKey, err := + derivedAddressInput( + ctx, params, accountID, getExtIndex, getIntIndex, deriveFn, + ) + if err != nil { + return nil, err + } + + row, err := createFn(ctx, accountID, addrType, branch, index, scriptPubKey) + if err != nil { + return nil, fmt.Errorf("create address: %w", err) + } + + rowIDVal := rowID(row) + + id, convertedAcctID, err := convertAddressIDs(rowIDVal, accountID) + if err != nil { + return nil, err + } + + return &AddressInfo{ + ID: id, + AccountID: convertedAcctID, + AddrType: addrType, + CreatedAt: rowCreatedAt(row), + Origin: DerivedAccount, + Branch: branch, + Index: index, + IsWatchOnly: false, + }, nil +} + +// derivedAddressInput encapsulates the logic to prepare inputs for address +// derivation, including schema lookup, branch/type selection, index +// allocation with overflow check, accountID conversion, and derivation. +func derivedAddressInput(ctx context.Context, + params NewDerivedAddressParams, accountID int64, + getExtIndex func(context.Context, int64) (int64, error), + getIntIndex func(context.Context, int64) (int64, error), + deriveFn AddressDerivationFunc) (AddressType, uint32, uint32, + []byte, error) { + + addrSchema, err := getAddrSchemaForScope(params.Scope) + if err != nil { + return 0, 0, 0, nil, err + } + + var ( + branch uint32 + addrType AddressType + getIdx func(context.Context, int64) (int64, error) + ) + + if params.Change { + branch = 1 + addrType = addrSchema.InternalAddrType + getIdx = getIntIndex + } else { + addrType = addrSchema.ExternalAddrType + getIdx = getExtIndex + } + + indexValue, err := getIdx(ctx, accountID) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "get next address index: %w", err) + } + + if indexValue > math.MaxUint32 { + return 0, 0, 0, nil, ErrMaxAddressIndexReached + } + + index, err := int64ToUint32(indexValue) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "address index: %w", err) + } + + acctID, err := int64ToUint32(accountID) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "account ID: %w", err) + } + + derivedData, err := deriveFn(ctx, acctID, branch, index) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "derive address: %w", err) + } + + return addrType, branch, index, derivedData.ScriptPubKey, nil +} + +// newDerivedAddressWithTx combines transaction execution, account lookup, +// and derived address creation in one helper. +func newDerivedAddressWithTx[QTX any, AccountRow any, + AccountParams any, AddrRow any](ctx context.Context, + params NewDerivedAddressParams, + executeTx func(context.Context, func(QTX) error) error, + adapters derivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + var result *AddressInfo + + err := executeTx(ctx, func(qtx QTX) error { + row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + if err == nil { + info, errAddr := createDerivedAddress( + ctx, params, adapters.getAccountID(row), + adapters.getExtIndex(qtx), + adapters.getIntIndex(qtx), + adapters.createAddr(qtx), + adapters.rowID, + adapters.rowCreatedAt, deriveFn) + if errAddr != nil { + return errAddr + } + + result = info + + return nil + } + + if errors.Is(err, sql.ErrNoRows) { + key := accountKeyFromParams(params) + + return fmt.Errorf("account %q in scope %d/%d: %w", + key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + } + + return fmt.Errorf("get account: %w", err) + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// newImportedAddressWithTx combines transaction execution, account lookup, +// and imported address creation in one helper. +func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, + CreateArgs any, AddrRow any, SecretParams any]( + ctx context.Context, params NewImportedAddressParams, + executeTx func(context.Context, func(QTX) error) error, + adapters importedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, + AddrRow, SecretParams]) (*AddressInfo, error) { + + validationErr := params.validate() + if validationErr != nil { + return nil, validationErr + } + + var result *AddressInfo + + err := executeTx(ctx, func(qtx QTX) error { + row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + if err == nil { + acctID := adapters.getAccountID(row) + + info, errAddr := newImportedAddressTx( + ctx, adapters.createAddr(qtx), + adapters.createParams(acctID, params), + adapters.insertSecret, qtx, + adapters.secretParams, params, acctID, + adapters.rowID, + adapters.rowCreatedAt) + if errAddr != nil { + return errAddr + } + + result = info + + return nil + } + + if errors.Is(err, sql.ErrNoRows) { + key := accountKeyFromImportedParams(params) + + return fmt.Errorf("account %q in scope %d/%d: %w", + key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + } + + return fmt.Errorf("get account: %w", err) + }) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go new file mode 100644 index 0000000000..76c3d42334 --- /dev/null +++ b/wallet/internal/db/addresses_pg.go @@ -0,0 +1,304 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetAddress retrieves information about a specific address, identified by +// its script pubkey. +func (w *PostgresWalletDB) GetAddress(ctx context.Context, + query GetAddressQuery) (*AddressInfo, error) { + + getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + error) { + + return getAddress( + ctx, w.queries.GetAddressByScriptPubKey, + sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: q.ScriptPubKey, + WalletID: int64(q.WalletID), + }, pgAddressRowToInfo, + ) + } + + return getAddressByQuery(ctx, query, getByScript) +} + +// ListAddresses returns a slice of AddressInfo for all addresses in a given +// account. +func (w *PostgresWalletDB) ListAddresses(ctx context.Context, + query ListAddressesQuery) ([]AddressInfo, error) { + + return listAddresses( + ctx, w.queries.ListAddressesByAccount, + sqlcpg.ListAddressesByAccountParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: query.AccountName, + }, pgAddressRowToInfo, + ) +} + +// GetAddressSecret retrieves the encrypted secret information for an address. +func (w *PostgresWalletDB) GetAddressSecret(ctx context.Context, + addressID uint32) (*AddressSecret, error) { + + return getAddressSecret( + ctx, w.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ) +} + +// NewDerivedAddress creates a new address for a given account and key +// scope. +func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, + params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + adapters := derivedAddressAdapters[ + *sqlcpg.Queries, + sqlcpg.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcpg.CreateDerivedAddressRow]{ + getAccount: pgGetAccountFromKey(w.queries), + accountParams: accountKeyFromParams, + getAccountID: newDerivedAddressGetAccountIDPg, + getExtIndex: newDerivedAddressGetExtIndexPg, + getIntIndex: newDerivedAddressGetIntIndexPg, + createAddr: newDerivedAddressCreateAddrPg, + rowID: newDerivedAddressRowIDPg, + rowCreatedAt: newDerivedAddressRowCreatedAtPg, + } + + return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) +} + +// NewImportedAddress imports a new address, script, or private key. +func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) { + + adapters := importedAddressAdapters[ + *sqlcpg.Queries, + sqlcpg.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcpg.CreateImportedAddressParams, + sqlcpg.CreateImportedAddressRow, + sqlcpg.InsertAddressSecretParams]{ + getAccount: pgGetAccountFromKey(w.queries), + accountParams: accountKeyFromImportedParams, + getAccountID: newImportedAddressGetAccountIDPg, + createAddr: pgCreateImportedAddress, + createParams: createImportedAddressParamsPg, + insertSecret: pgInsertAddressSecret, + secretParams: insertAddressSecretParamsPg, + rowID: importedAddressRowIDPg, + rowCreatedAt: importedAddressRowCreatedAtPg, + } + + return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) +} + +// pgGetAccountFromKey returns a helper to look up accounts by key. +func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, + accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + + return func(ctx context.Context, + key accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + error) { + + return qtx.GetAccountByWalletScopeAndName( + ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ + WalletID: key.walletID, + Purpose: key.purpose, + CoinType: key.coinType, + AccountName: key.accountName, + }, + ) + } +} + +// newDerivedAddressGetAccountIDPg extracts the account ID from a row. +func newDerivedAddressGetAccountIDPg( + row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// newDerivedAddressGetExtIndexPg returns the external index query. +func newDerivedAddressGetExtIndexPg(qtx *sqlcpg.Queries) func(context.Context, + int64) (int64, error) { + + return qtx.GetAndIncrementNextExternalIndex +} + +// newDerivedAddressGetIntIndexPg returns the internal index query. +func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, + int64) (int64, error) { + + return qtx.GetAndIncrementNextInternalIndex +} + +// newDerivedAddressCreateAddrPg returns the derived address insert helper. +func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, + int64, AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, + error) { + + return func(ctx context.Context, accountID int64, addrType AddressType, + branch uint32, index uint32, + scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + + return qtx.CreateDerivedAddress( + ctx, sqlcpg.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(addrType), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + } +} + +// newDerivedAddressRowIDPg returns the created address ID. +func newDerivedAddressRowIDPg(row sqlcpg.CreateDerivedAddressRow) int64 { + return row.ID +} + +// newDerivedAddressRowCreatedAtPg returns the CreatedAt timestamp. +func newDerivedAddressRowCreatedAtPg( + row sqlcpg.CreateDerivedAddressRow) time.Time { + + return row.CreatedAt +} + +// newImportedAddressGetAccountIDPg extracts the account ID from a row. +func newImportedAddressGetAccountIDPg( + row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// pgCreateImportedAddress returns the imported address insert helper. +func pgCreateImportedAddress(qtx *sqlcpg.Queries) func(context.Context, + sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, + error) { + + return qtx.CreateImportedAddress +} + +// pgInsertAddressSecret returns the secret insert helper. +func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, + sqlcpg.InsertAddressSecretParams) error { + + return qtx.InsertAddressSecret +} + +// createImportedAddressParamsPg maps imported params to sqlc params. +func createImportedAddressParamsPg(accountID int64, + params NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + + return sqlcpg.CreateImportedAddressParams{ + AccountID: accountID, + ScriptPubKey: params.ScriptPubKey, + TypeID: int16(params.AddressType), + PubKey: params.PubKey, + } +} + +// insertAddressSecretParamsPg maps imported params to secret params. +func insertAddressSecretParamsPg(addressID int64, + params NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + + return sqlcpg.InsertAddressSecretParams{ + AddressID: addressID, + EncryptedPrivKey: params.EncryptedPrivateKey, + EncryptedScript: params.EncryptedScript, + } +} + +// importedAddressRowIDPg returns the created address ID. +func importedAddressRowIDPg(row sqlcpg.CreateImportedAddressRow) int64 { + return row.ID +} + +// importedAddressRowCreatedAtPg returns the CreatedAt timestamp. +func importedAddressRowCreatedAtPg( + row sqlcpg.CreateImportedAddressRow) time.Time { + + return row.CreatedAt +} + +// pgAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the +// db.AddressSecret type used by the wallet, handling null value conversions. +// Returns ErrSecretNotFound if the address exists but has no secret. +func pgAddressSecretRowToSecret(row sqlcpg.GetAddressSecretRow) (*AddressSecret, + error) { + + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + +// pgAddressInfoRow is a type constraint that unifies all PostgreSQL address +// row types that share the same field structure. This enables a single +// generic conversion function to handle all address query result types. +type pgAddressInfoRow interface { + sqlcpg.GetAddressByScriptPubKeyRow | + sqlcpg.ListAddressesByAccountRow +} + +// pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo +// struct. +func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcpg.GetAddressByScriptPubKeyRow(row) + + info, err := addressRowToInfo(addressInfoRow[int16, int16]{ + ID: base.ID, + AccountID: base.AccountID, + TypeID: base.TypeID, + OriginID: base.OriginID, + HasPrivateKey: base.HasPrivateKey, + HasScript: base.HasScript, + CreatedAt: base.CreatedAt, + AddressBranch: base.AddressBranch, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int16], + IDToOrigin: idToOrigin[int16], + }) + if err != nil { + return nil, err + } + + return info, nil +} diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go new file mode 100644 index 0000000000..f89ea9143c --- /dev/null +++ b/wallet/internal/db/addresses_sqlite.go @@ -0,0 +1,308 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetAddress retrieves information about a specific address, identified by +// its script pubkey. +func (w *SQLiteWalletDB) GetAddress(ctx context.Context, + query GetAddressQuery) (*AddressInfo, error) { + + getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + error) { + + return getAddress( + ctx, w.queries.GetAddressByScriptPubKey, + sqlcsqlite.GetAddressByScriptPubKeyParams{ + WalletID: int64(q.WalletID), + ScriptPubKey: q.ScriptPubKey, + }, sqliteAddressRowToInfo, + ) + } + + return getAddressByQuery(ctx, query, getByScript) +} + +// ListAddresses returns a slice of AddressInfo for all addresses in a given +// account. +func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, + query ListAddressesQuery) ([]AddressInfo, error) { + + return listAddresses( + ctx, w.queries.ListAddressesByAccount, + sqlcsqlite.ListAddressesByAccountParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: query.AccountName, + }, sqliteAddressRowToInfo, + ) +} + +// GetAddressSecret retrieves the encrypted secret information for an address. +func (w *SQLiteWalletDB) GetAddressSecret(ctx context.Context, + addressID uint32) (*AddressSecret, error) { + + return getAddressSecret( + ctx, w.queries.GetAddressSecret, addressID, + sqliteAddressSecretRowToSecret, + ) +} + +// NewDerivedAddress creates a new address for a given account and key +// scope. +func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, + params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + adapters := derivedAddressAdapters[ + *sqlcsqlite.Queries, + sqlcsqlite.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcsqlite.CreateDerivedAddressRow]{ + getAccount: sqliteGetAccountFromKey(w.queries), + accountParams: accountKeyFromParams, + getAccountID: newDerivedAddressGetAccountIDSQLite, + getExtIndex: newDerivedAddressGetExtIndexSQLite, + getIntIndex: newDerivedAddressGetIntIndexSQLite, + createAddr: newDerivedAddressCreateAddrSQLite, + rowID: newDerivedAddressRowIDSQLite, + rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + } + + return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) +} + +// NewImportedAddress imports a new address, script, or private key. +func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) { + + adapters := importedAddressAdapters[ + *sqlcsqlite.Queries, + sqlcsqlite.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcsqlite.CreateImportedAddressParams, + sqlcsqlite.CreateImportedAddressRow, + sqlcsqlite.InsertAddressSecretParams]{ + getAccount: sqliteGetAccountFromKey(w.queries), + accountParams: accountKeyFromImportedParams, + getAccountID: newImportedAddressGetAccountIDSQLite, + createAddr: sqliteCreateImportedAddress, + createParams: createImportedAddressParamsSQLite, + insertSecret: sqliteInsertAddressSecret, + secretParams: insertAddressSecretParamsSQLite, + rowID: importedAddressRowIDSQLite, + rowCreatedAt: importedAddressRowCreatedAtSQLite, + } + + return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) +} + +// sqliteGetAccountFromKey returns a helper to look up accounts by key. +func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, + accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + + return func(ctx context.Context, + key accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + error) { + + return qtx.GetAccountByWalletScopeAndName( + ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + WalletID: key.walletID, + Purpose: key.purpose, + CoinType: key.coinType, + AccountName: key.accountName, + }, + ) + } +} + +// newDerivedAddressGetAccountIDSQLite extracts the account ID from a row. +func newDerivedAddressGetAccountIDSQLite( + row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// newDerivedAddressGetExtIndexSQLite returns the external index query. +func newDerivedAddressGetExtIndexSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + + return qtx.GetAndIncrementNextExternalIndex +} + +// newDerivedAddressGetIntIndexSQLite returns the internal index query. +func newDerivedAddressGetIntIndexSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + + return qtx.GetAndIncrementNextInternalIndex +} + +// newDerivedAddressCreateAddrSQLite returns the derived address insert helper. +func newDerivedAddressCreateAddrSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64, AddressType, uint32, + uint32, []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + + return func(ctx context.Context, accountID int64, addrType AddressType, + branch uint32, index uint32, + scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + + return qtx.CreateDerivedAddress( + ctx, sqlcsqlite.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(addrType), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + } +} + +// newDerivedAddressRowIDSQLite returns the created address ID. +func newDerivedAddressRowIDSQLite( + row sqlcsqlite.CreateDerivedAddressRow) int64 { + + return row.ID +} + +// newDerivedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. +func newDerivedAddressRowCreatedAtSQLite( + row sqlcsqlite.CreateDerivedAddressRow) time.Time { + + return row.CreatedAt +} + +// newImportedAddressGetAccountIDSQLite extracts the account ID from a row. +func newImportedAddressGetAccountIDSQLite( + row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// sqliteCreateImportedAddress returns the imported address insert helper. +func sqliteCreateImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, + sqlcsqlite.CreateImportedAddressParams) ( + sqlcsqlite.CreateImportedAddressRow, error) { + + return qtx.CreateImportedAddress +} + +// sqliteInsertAddressSecret returns the secret insert helper. +func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, + sqlcsqlite.InsertAddressSecretParams) error { + + return qtx.InsertAddressSecret +} + +// createImportedAddressParamsSQLite maps imported params to sqlc params. +func createImportedAddressParamsSQLite(accountID int64, + params NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + + return sqlcsqlite.CreateImportedAddressParams{ + AccountID: accountID, + ScriptPubKey: params.ScriptPubKey, + TypeID: int64(params.AddressType), + PubKey: params.PubKey, + } +} + +// importedAddressRowIDSQLite returns the created address ID. +func importedAddressRowIDSQLite(row sqlcsqlite.CreateImportedAddressRow) int64 { + return row.ID +} + +// importedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. +func importedAddressRowCreatedAtSQLite( + row sqlcsqlite.CreateImportedAddressRow) time.Time { + + return row.CreatedAt +} + +// insertAddressSecretParamsSQLite maps imported params to secret params. +func insertAddressSecretParamsSQLite(addressID int64, + params NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + + return sqlcsqlite.InsertAddressSecretParams{ + AddressID: addressID, + EncryptedPrivKey: params.EncryptedPrivateKey, + EncryptedScript: params.EncryptedScript, + } +} + +// sqliteAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the +// db.AddressSecret type used by the wallet, handling null value conversions. +// Returns ErrSecretNotFound if the secret is missing. +func sqliteAddressSecretRowToSecret( + row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { + + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + +// sqliteAddressInfoRow is a type constraint union that represents all SQLite +// address row types that share the same field structure. This enables a +// single generic conversion function to handle all address query result types. +type sqliteAddressInfoRow interface { + sqlcsqlite.GetAddressByScriptPubKeyRow | + sqlcsqlite.ListAddressesByAccountRow +} + +// sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo +// struct. +func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, + error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) + + info, err := addressRowToInfo(addressInfoRow[int64, int64]{ + ID: base.ID, + AccountID: base.AccountID, + TypeID: base.TypeID, + OriginID: base.OriginID, + HasPrivateKey: base.HasPrivateKey, + HasScript: base.HasScript, + CreatedAt: base.CreatedAt, + AddressBranch: base.AddressBranch, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int64], + IDToOrigin: idToOrigin[int64], + }) + if err != nil { + return nil, err + } + + return info, nil +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 1bc6793e01..0605ac9d22 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -27,6 +27,10 @@ var ( // database. ErrAccountNotFound = errors.New("account not found") + // ErrAddressNotFound is returned when an address is not found in the + // database. + ErrAddressNotFound = errors.New("address not found") + // ErrKeyScopeNotFound is returned when a key scope is not found in the // database. ErrKeyScopeNotFound = errors.New("key scope not found") @@ -41,6 +45,14 @@ var ( "exactly one of Name or AccountNumber must be provided", ) + // ErrInvalidAddressQuery is returned when GetAddressQuery has invalid + // field combinations. + ErrInvalidAddressQuery = errors.New("ScriptPubKey must be provided") + + // ErrMissingScriptPubKey is returned when creating an imported + // address without the required script public key. + ErrMissingScriptPubKey = errors.New("script pubkey required") + // ErrMissingAccountPublicKey is returned when an imported account is // missing the encrypted public key. ErrMissingAccountPublicKey = errors.New( @@ -55,6 +67,11 @@ var ( // within a key scope because the account number counter has reached its // maximum representable value. ErrMaxAccountNumberReached = errors.New("max account number reached") + + // ErrMaxAddressIndexReached indicates that no more addresses can be + // created within a branch because the address index counter has reached + // its maximum representable value. + ErrMaxAddressIndexReached = errors.New("max address index reached") ) // WalletStore defines the methods for wallet-level operations. From ff2c8aaf09463ecb69c6dbdbe5de95b06b72ebb9 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 12 Jan 2026 17:02:31 -0300 Subject: [PATCH 076/695] wallet: add address counting to account --- wallet/internal/db/accounts_common.go | 117 ++++++--- wallet/internal/db/accounts_pg.go | 24 +- wallet/internal/db/accounts_sqlite.go | 24 +- .../internal/db/queries/postgres/accounts.sql | 73 ++++-- .../internal/db/queries/sqlite/accounts.sql | 73 ++++-- .../internal/db/sqlc/postgres/accounts.sql.go | 229 +++++++++++++----- .../internal/db/sqlc/sqlite/accounts.sql.go | 229 +++++++++++++----- 7 files changed, 560 insertions(+), 209 deletions(-) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 8c2681f3ad..24324e5786 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -54,6 +54,9 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool @@ -66,13 +69,52 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { IDToOriginType func(AccOriginId) (AccountOrigin, error) } +// getKeyCounts converts external, internal, and imported key counts from +// int64 to uint32 and handles errors. +func getKeyCounts(external, internal, imported int64) (uint32, uint32, + uint32, error) { + + externalKeyCount, err := int64ToUint32(external) + if err != nil { + return 0, 0, 0, fmt.Errorf("external key count: %w", err) + } + + internalKeyCount, err := int64ToUint32(internal) + if err != nil { + return 0, 0, 0, fmt.Errorf("internal key count: %w", err) + } + + importedKeyCount, err := int64ToUint32(imported) + if err != nil { + return 0, 0, 0, fmt.Errorf("imported key count: %w", err) + } + + return externalKeyCount, internalKeyCount, importedKeyCount, nil +} + +// getAddrTypes extracts the internal and external address types from the row +// and handles errors. +func getAddrTypes[AddrTypeId, AccOriginId any]( + row accountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, + error) { + + internalType, err := row.IDToAddrType(row.InternalTypeID) + if err != nil { + return 0, 0, fmt.Errorf("internal type: %w", err) + } + + externalType, err := row.IDToAddrType(row.ExternalTypeID) + if err != nil { + return 0, 0, fmt.Errorf("external type: %w", err) + } + + return internalType, externalType, nil +} + // accountPropsRowToProps converts a database row containing full account // properties into an AccountProperties struct. The idToAddrType function is // used to convert the internal and external address type IDs to AddressType // values. -// -// TODO(stingelin): Add address counting support after address management is -// implemented. func accountPropsRowToProps[AddrTypeId, AccOriginId any]( row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { @@ -101,14 +143,9 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( return nil, fmt.Errorf("coin type: %w", err) } - internalType, err := row.IDToAddrType(row.InternalTypeID) + internalType, externalType, err := getAddrTypes(row) if err != nil { - return nil, fmt.Errorf("internal type: %w", err) - } - - externalType, err := row.IDToAddrType(row.ExternalTypeID) - if err != nil { - return nil, fmt.Errorf("external type: %w", err) + return nil, err } var fingerprint uint32 @@ -119,13 +156,20 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( } } + externalKeyCount, internalKeyCount, importedKeyCount, err := getKeyCounts( + row.ExternalKeyCount, row.InternalKeyCount, row.ImportedKeyCount, + ) + if err != nil { + return nil, err + } + return &AccountProperties{ AccountNumber: accountNum, AccountName: row.AccountName, Origin: origin, - ExternalKeyCount: 0, - InternalKeyCount: 0, - ImportedKeyCount: 0, + ExternalKeyCount: externalKeyCount, + InternalKeyCount: internalKeyCount, + ImportedKeyCount: importedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterKeyFingerprint: fingerprint, KeyScope: KeyScope{ @@ -186,23 +230,22 @@ func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { } // buildAccountInfo creates an AccountInfo with the provided values and zeroed -// balances and key counts while we do not yet support address counting. +// balances while we do not yet support balance tracking. // -// TODO(stingelin): Add address counting support after address management is -// implemented. // TODO(stingelin): Add balance tracking support after transaction management is // implemented. func buildAccountInfo(accountNum uint32, accountName string, - origin AccountOrigin, isWatchOnly bool, createdAt time.Time, + origin AccountOrigin, externalKeyCount, internalKeyCount, + importedKeyCount uint32, isWatchOnly bool, createdAt time.Time, scope KeyScope) *AccountInfo { return &AccountInfo{ AccountNumber: accountNum, AccountName: accountName, Origin: origin, - ExternalKeyCount: 0, - InternalKeyCount: 0, - ImportedKeyCount: 0, + ExternalKeyCount: externalKeyCount, + InternalKeyCount: internalKeyCount, + ImportedKeyCount: importedKeyCount, ConfirmedBalance: 0, UnconfirmedBalance: 0, IsWatchOnly: isWatchOnly, @@ -224,14 +267,17 @@ func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { // accountInfoRow represents the raw database fields needed to construct // AccountInfo. type accountInfoRow[AccOriginId any] struct { - AccountNumber sql.NullInt64 - AccountName string - OriginID AccOriginId - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 - IDToOriginType func(AccOriginId) (AccountOrigin, error) + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + IDToOriginType func(AccOriginId) (AccountOrigin, error) } // accountRowToInfo converts raw database field values into an AccountInfo @@ -264,12 +310,17 @@ func accountRowToInfo[AccOriginId any]( return nil, fmt.Errorf("coin type: %w", err) } + externalKeyCount, internalKeyCount, importedKeyCount, err := getKeyCounts( + row.ExternalKeyCount, row.InternalKeyCount, row.ImportedKeyCount, + ) + if err != nil { + return nil, err + } + return buildAccountInfo( - accountNum, row.AccountName, origin, row.IsWatchOnly, - row.CreatedAt, KeyScope{ - Purpose: purposeNum, - Coin: coinTypeNum, - }, + accountNum, row.AccountName, origin, externalKeyCount, internalKeyCount, + importedKeyCount, row.IsWatchOnly, row.CreatedAt, + KeyScope{Purpose: purposeNum, Coin: coinTypeNum}, ), nil } diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index b8d6e17ff8..3aad53c15b 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -100,7 +100,7 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, } info = buildAccountInfo( - accNumber, params.Name, DerivedAccount, false, + accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -197,6 +197,9 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, + ExternalKeyCount: row.ExternalKeyCount, + InternalKeyCount: row.InternalKeyCount, + ImportedKeyCount: row.ImportedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterFingerprint: row.MasterFingerprint, IsWatchOnly: row.IsWatchOnly, @@ -264,14 +267,17 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { base := sqlcpg.GetAccountByScopeAndNameRow(row) return accountRowToInfo(accountInfoRow[int16]{ - AccountNumber: base.AccountNumber, - AccountName: base.AccountName, - OriginID: base.OriginID, - IsWatchOnly: base.IsWatchOnly, - CreatedAt: base.CreatedAt, - Purpose: base.Purpose, - CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int16], + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + ExternalKeyCount: base.ExternalKeyCount, + InternalKeyCount: base.InternalKeyCount, + ImportedKeyCount: base.ImportedKeyCount, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int16], }) } diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index 94f665c1a0..a746a3c6f3 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -90,7 +90,7 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, } info = buildAccountInfo( - accNumber, params.Name, DerivedAccount, false, + accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -223,6 +223,9 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, + ExternalKeyCount: row.ExternalKeyCount, + InternalKeyCount: row.InternalKeyCount, + ImportedKeyCount: row.ImportedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterFingerprint: row.MasterFingerprint, IsWatchOnly: row.IsWatchOnly, @@ -260,14 +263,17 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, base := sqlcsqlite.GetAccountByScopeAndNameRow(row) return accountRowToInfo(accountInfoRow[int64]{ - AccountNumber: base.AccountNumber, - AccountName: base.AccountName, - OriginID: base.OriginID, - IsWatchOnly: base.IsWatchOnly, - CreatedAt: base.CreatedAt, - Purpose: base.Purpose, - CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int64], + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + ExternalKeyCount: base.ExternalKeyCount, + InternalKeyCount: base.InternalKeyCount, + ImportedKeyCount: base.ImportedKeyCount, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int64], }) } diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 9da524fa30..afa4e885e9 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -85,10 +85,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = $1 AND a.account_name = $2; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -100,10 +105,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = $1 AND a.account_number = $2; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = $1 AND a.account_number = $2 +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -115,14 +125,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_name = $4; + AND a.account_name = $4 +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -134,14 +149,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_number = $4; + AND a.account_number = $4 +GROUP BY a.id, ks.id; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -156,10 +176,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.id = $1; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.id = $1 +GROUP BY a.id, ks.id; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -172,10 +197,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletScope :many @@ -189,13 +219,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletAndName :many @@ -209,10 +244,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWallet :many @@ -226,10 +266,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index f6bfeb5f10..cea8dc577a 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -56,10 +56,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = ? AND a.account_name = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -71,10 +76,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = ? AND a.account_number = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = ? AND a.account_number = ? +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -86,14 +96,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_name = ?; + AND a.account_name = ? +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -105,14 +120,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_number = ?; + AND a.account_number = ? +GROUP BY a.id, ks.id; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -127,10 +147,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.id = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.id = ? +GROUP BY a.id, ks.id; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -143,10 +168,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletScope :many @@ -160,13 +190,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletAndName :many @@ -180,10 +215,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWallet :many @@ -197,10 +237,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index e6998c7c81..b3bd193e1b 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -181,10 +181,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -194,13 +199,16 @@ type GetAccountByScopeAndNameParams struct { type GetAccountByScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account name. @@ -216,6 +224,9 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -229,10 +240,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_number = $2 +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -242,13 +258,16 @@ type GetAccountByScopeAndNumberParams struct { type GetAccountByScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account number. @@ -264,6 +283,9 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -277,14 +299,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_name = $4 +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -296,13 +323,16 @@ type GetAccountByWalletScopeAndNameParams struct { type GetAccountByWalletScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account name. @@ -323,6 +353,9 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -336,14 +369,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_number = $4 +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -355,13 +393,16 @@ type GetAccountByWalletScopeAndNumberParams struct { type GetAccountByWalletScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account number. @@ -382,6 +423,9 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -398,10 +442,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = $1 +GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -416,6 +465,9 @@ type GetAccountPropsByIdRow struct { CoinType int64 InternalTypeID int16 ExternalTypeID int16 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns full account properties by account id. @@ -434,6 +486,9 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CoinType, &i.InternalTypeID, &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -479,22 +534,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` type ListAccountsByScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -517,6 +580,9 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -540,22 +606,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` type ListAccountsByWalletRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet, ordered by account number. Imported @@ -578,6 +652,9 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -601,10 +678,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -615,13 +697,16 @@ type ListAccountsByWalletAndNameParams struct { type ListAccountsByWalletAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet filtered by account name, ordered by account @@ -644,6 +729,9 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -667,13 +755,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -685,13 +778,16 @@ type ListAccountsByWalletScopeParams struct { type ListAccountsByWalletScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet and scope tuple, ordered by account number. @@ -714,6 +810,9 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 3eacdc0306..51c363249e 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -179,10 +179,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -192,13 +197,16 @@ type GetAccountByScopeAndNameParams struct { type GetAccountByScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account name. @@ -214,6 +222,9 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -227,10 +238,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_number = ? +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -240,13 +256,16 @@ type GetAccountByScopeAndNumberParams struct { type GetAccountByScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account number. @@ -262,6 +281,9 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -275,14 +297,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_name = ? +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -294,13 +321,16 @@ type GetAccountByWalletScopeAndNameParams struct { type GetAccountByWalletScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account name. @@ -321,6 +351,9 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -334,14 +367,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_number = ? +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -353,13 +391,16 @@ type GetAccountByWalletScopeAndNumberParams struct { type GetAccountByWalletScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account number. @@ -380,6 +421,9 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -396,10 +440,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = ? +GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -414,6 +463,9 @@ type GetAccountPropsByIdRow struct { CoinType int64 InternalTypeID int64 ExternalTypeID int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns full account properties by account id. @@ -432,6 +484,9 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CoinType, &i.InternalTypeID, &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -477,22 +532,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -515,6 +578,9 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -538,22 +604,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByWalletRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet, ordered by account number. Imported @@ -576,6 +650,9 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -599,10 +676,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -613,13 +695,16 @@ type ListAccountsByWalletAndNameParams struct { type ListAccountsByWalletAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet filtered by account name, ordered by account @@ -642,6 +727,9 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -665,13 +753,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -683,13 +776,16 @@ type ListAccountsByWalletScopeParams struct { type ListAccountsByWalletScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet and scope tuple, ordered by account number. @@ -712,6 +808,9 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } From c422aedac6656b7d54487984014785a439fc8fdb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 01:28:55 -0300 Subject: [PATCH 077/695] wallet: add AddressStore tests --- .../internal/db/itest/address_store_test.go | 1120 +++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 88 ++ .../internal/db/itest/fixtures_sqlite_test.go | 88 ++ 3 files changed, 1296 insertions(+) create mode 100644 wallet/internal/db/itest/address_store_test.go diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go new file mode 100644 index 0000000000..8beccd5cf4 --- /dev/null +++ b/wallet/internal/db/itest/address_store_test.go @@ -0,0 +1,1120 @@ +//go:build itest + +package itest + +import ( + "context" + "encoding/binary" + "math" + "sort" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// mockDeriveFunc is a test helper that returns a mock AddressDerivationFunc +// for testing NewDerivedAddress. It generates deterministic script_pub_key +// values based on accountID, branch, and index to ensure uniqueness. +func mockDeriveFunc() db.AddressDerivationFunc { + return func(ctx context.Context, accountID uint32, branch uint32, + index uint32) (*db.DerivedAddressData, error) { + + scriptPubKey := make([]byte, 20) + binary.BigEndian.PutUint32(scriptPubKey[0:4], accountID) + binary.BigEndian.PutUint32(scriptPubKey[4:8], branch) + binary.BigEndian.PutUint32(scriptPubKey[8:12], index) + return &db.DerivedAddressData{ + ScriptPubKey: scriptPubKey, + }, nil + } +} + +func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, + scope db.KeyScope, accountName string, change bool) *db.AddressInfo { + + t.Helper() + + info, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Change: change, + }, mockDeriveFunc(), + ) + require.NoError(t, err) + + return info +} + +func createDerivedAddresses(t *testing.T, store db.AddressStore, + walletID uint32, scope db.KeyScope, accountName string, change bool, + count int) []db.AddressInfo { + + t.Helper() + + addresses := make([]db.AddressInfo, 0, count) + for i := 0; i < count; i++ { + info := newDerivedAddress( + t, store, walletID, scope, accountName, change, + ) + addresses = append(addresses, *info) + } + + return addresses +} + +func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, accountName string) *db.AccountInfo { + + t.Helper() + + account, err := store.GetAccount( + t.Context(), getAccountQueryByName(walletID, scope, accountName), + ) + require.NoError(t, err) + + return account +} + +// TestNewImportedAddress verifies that NewImportedAddress correctly imports +// addresses of different types, both watch-only and spendable. +func TestNewImportedAddress(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-imported-addresses") + + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + pubKey := privKey.PubKey() + + p2pkhAddr, err := address.NewAddressPubKeyHash( + address.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + p2wpkhAddr, err := address.NewAddressWitnessPubKeyHash( + address.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + p2trAddr, err := address.NewAddressTaproot( + pubKey.SerializeCompressed()[1:], &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + testCases := []struct { + name string + addr address.Address + scope db.KeyScope + expectedAddrType db.AddressType + providePrivateKey bool + }{ + { + name: "P2PKH watch-only", + addr: p2pkhAddr, + scope: db.KeyScopeBIP0044, + expectedAddrType: db.PubKeyHash, + providePrivateKey: false, + }, + { + name: "P2PKH spendable", + addr: p2pkhAddr, + scope: db.KeyScopeBIP0044, + expectedAddrType: db.PubKeyHash, + providePrivateKey: true, + }, + { + name: "P2WPKH watch-only", + addr: p2wpkhAddr, + scope: db.KeyScopeBIP0084, + expectedAddrType: db.WitnessPubKey, + providePrivateKey: false, + }, + { + name: "P2WPKH spendable", + addr: p2wpkhAddr, + scope: db.KeyScopeBIP0084, + expectedAddrType: db.WitnessPubKey, + providePrivateKey: true, + }, + { + name: "P2TR watch-only", + addr: p2trAddr, + scope: db.KeyScopeBIP0086, + expectedAddrType: db.TaprootPubKey, + providePrivateKey: false, + }, + { + name: "P2TR spendable", + addr: p2trAddr, + scope: db.KeyScopeBIP0086, + expectedAddrType: db.TaprootPubKey, + providePrivateKey: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: tc.scope, + AddressType: tc.expectedAddrType, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + } + + if tc.providePrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + // Import the address. + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + // Verify AddressInfo fields. + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.ImportedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, uint32(0), info.Branch) + require.Equal(t, uint32(0), info.Index) + require.NotNil(t, info.PubKey) + require.NotNil(t, info.ScriptPubKey) + require.Equal(t, tc.expectedAddrType, info.AddrType) + require.Equal(t, !tc.providePrivateKey, info.IsWatchOnly) + + // Verify account imported_key_count incremented. + account, err := store.GetAccount( + t.Context(), getAccountQueryByName( + walletID, tc.scope, "imported", + ), + ) + require.NoError(t, err) + require.Greater(t, account.ImportedKeyCount, uint32(0)) + + // Verify address_secrets row for imported addresses. + addressID := getAddressID( + t, queries, params.ScriptPubKey, walletID, + ) + + secret, err := getAddressSecret(t, queries, addressID) + require.NoError(t, err) + if tc.providePrivateKey { + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + require.Empty(t, secret.EncryptedScript) + } else { + require.Empty(t, secret.EncryptedPrivKey) + require.Empty(t, secret.EncryptedScript) + } + }) + } +} + +// TestNewImportedAddressWithEncryptedScript verifies that NewImportedAddress +// correctly imports script-based addresses (P2SH, P2WSH) with EncryptedScript, +// and that the EncryptedScript is stored and retrievable via GetAddressSecret. +func TestNewImportedAddressWithEncryptedScript(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-encrypted-script") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") + + redeemScript := RandomBytes(32) + witnessScript := RandomBytes(48) + + testCases := []struct { + name string + scope db.KeyScope + addressType db.AddressType + encryptedScript []byte + hasPrivateKey bool + hasScript bool + expectedAddrType db.AddressType + }{ + { + name: "P2SH with EncryptedScript only (watch-only)", + scope: db.KeyScopeBIP0044, + addressType: db.ScriptHash, + encryptedScript: redeemScript, + hasPrivateKey: false, + hasScript: true, + expectedAddrType: db.ScriptHash, + }, + { + name: "P2SH with both EncryptedPrivateKey and EncryptedScript", + scope: db.KeyScopeBIP0044, + addressType: db.ScriptHash, + encryptedScript: redeemScript, + hasPrivateKey: true, + hasScript: true, + expectedAddrType: db.ScriptHash, + }, + { + name: "P2WSH with EncryptedScript only (watch-only)", + scope: db.KeyScopeBIP0049Plus, + addressType: db.WitnessScript, + encryptedScript: witnessScript, + hasPrivateKey: false, + hasScript: true, + expectedAddrType: db.WitnessScript, + }, + { + name: "P2WSH with both EncryptedPrivateKey and EncryptedScript", + scope: db.KeyScopeBIP0049Plus, + addressType: db.WitnessScript, + encryptedScript: witnessScript, + hasPrivateKey: true, + hasScript: true, + expectedAddrType: db.WitnessScript, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + scriptPubKey := RandomBytes(32) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: tc.scope, + AddressType: tc.addressType, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + EncryptedScript: tc.encryptedScript, + } + + if tc.hasPrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.ImportedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, uint32(0), info.Branch) + require.Equal(t, uint32(0), info.Index) + require.NotNil(t, info.PubKey) + require.NotNil(t, info.ScriptPubKey) + require.Equal(t, tc.expectedAddrType, info.AddrType) + + addressID := getAddressID( + t, queries, params.ScriptPubKey, walletID, + ) + + secret, err := getAddressSecret(t, queries, addressID) + require.NoError(t, err) + + require.Equal(t, tc.encryptedScript, secret.EncryptedScript) + + if tc.hasPrivateKey { + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + } + }) + } +} + +// TestNewImportedAddressDuplicate verifies that importing an address with +// a duplicate ScriptPubKey fails with a constraint error. +func TestNewImportedAddressDuplicate(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-import") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + // Set up encryption parameters (same for both imports). + scriptPubKey := RandomBytes(32) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + EncryptedPrivateKey: RandomBytes(32), + } + + // Import address first time (should succeed). + _, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + // Attempt to import with same ScriptPubKey (should fail). + _, err = store.NewImportedAddress(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") + require.ErrorContains(t, err, "script_pub_key") +} + +// TestGetAddressSecret verifies that GetAddressSecret correctly retrieves +// address secrets for watch-only imported addresses and returns an error for +// spendable addresses or non-existent address IDs. +func TestGetAddressSecret(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-secrets") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + + testCases := []struct { + name string + providePrivateKey bool + shouldHaveSecret bool + }{ + { + name: "spendable import", + providePrivateKey: true, + shouldHaveSecret: true, + }, + { + name: "watch-only import", + providePrivateKey: false, + shouldHaveSecret: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + } + + if tc.providePrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + info, errNewAddr := store.NewImportedAddress(t.Context(), params) + require.NoError(t, errNewAddr) + + if tc.shouldHaveSecret { + secret, err := store.GetAddressSecret(t.Context(), info.ID) + require.NoError(t, err) + require.NotNil(t, secret) + require.Equal(t, info.ID, secret.AddressID) + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + require.Empty(t, secret.EncryptedScript) + } else { + _, err := store.GetAddressSecret(t.Context(), info.ID) + require.ErrorIs(t, err, db.ErrSecretNotFound) + } + }) + } + + // Test non-existent address ID. + t.Run("non-existent address", func(t *testing.T) { + _, err := store.GetAddressSecret(t.Context(), 999999) + require.ErrorIs(t, err, db.ErrAddressNotFound) + }) +} + +// TestGetAddress verifies that GetAddress correctly retrieves addresses by +// ID and by encrypted script pubkey, and returns appropriate errors for +// invalid or non-existent queries. +func TestGetAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-address") + + tests := []struct { + name string + setupFunc func(t *testing.T) db.GetAddressQuery + wantErr error + validate func(t *testing.T, addr *db.AddressInfo) + }{ + { + name: "get by encrypted script pubkey", + setupFunc: func(t *testing.T) db.GetAddressQuery { + createImportedAccount( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + + script := RandomBytes(32) + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: script, + } + _, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: script, + } + }, + validate: func(t *testing.T, addr *db.AddressInfo) { + require.NotNil(t, addr.ScriptPubKey) + require.Equal(t, db.ImportedAccount, addr.Origin) + }, + }, + { + name: "address not found by script", + setupFunc: func(t *testing.T) db.GetAddressQuery { + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: RandomBytes(32), + } + }, + wantErr: db.ErrAddressNotFound, + }, + { + name: "invalid query - empty script pubkey", + setupFunc: func(t *testing.T) db.GetAddressQuery { + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: nil, + } + }, + wantErr: db.ErrInvalidAddressQuery, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + query := tc.setupFunc(t) + addr, err := store.GetAddress(t.Context(), query) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, addr) + return + } + + require.NoError(t, err) + require.NotNil(t, addr) + if tc.validate != nil { + tc.validate(t, addr) + } + }) + } +} + +// TestListAddresses verifies that ListAddresses correctly returns all +// addresses for an account in a specified scope, filters by scope +// appropriately, and handles empty results without error. +func TestListAddresses(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses") + + tests := []struct { + name string + setupFunc func(t *testing.T) db.ListAddressesQuery + wantCount int + wantErr error + validate func(t *testing.T, addrs []db.AddressInfo) + }{ + { + name: "list multiple addresses for account", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0044, "test-account", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0044, "test-account", + false, 5, + ) + + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "test-account", + } + }, + wantCount: 5, + validate: func(t *testing.T, addrs []db.AddressInfo) { + require.Len(t, addrs, 5) + for i, addr := range addrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + require.Equal(t, db.DerivedAccount, addr.Origin) + } + }, + }, + { + name: "list addresses - empty result", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, "empty-account", + ) + + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "empty-account", + } + }, + wantCount: 0, + }, + { + name: "list addresses filters by scope correctly", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + // Create accounts in different scopes. + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + ) + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + false, 3, + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + false, 2, + ) + + // Query only BIP0044 scope. + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "bip44-multi", + } + }, + wantCount: 3, + validate: func(t *testing.T, addrs []db.AddressInfo) { + require.Len(t, addrs, 3) + for _, addr := range addrs { + require.Equal(t, db.DerivedAccount, addr.Origin) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + query := tc.setupFunc(t) + addrs, err := store.ListAddresses(t.Context(), query) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Len(t, addrs, tc.wantCount) + + if tc.validate != nil { + tc.validate(t, addrs) + } + }) + } +} + +// TestNewDerivedAddress verifies that NewDerivedAddress correctly creates +// derived addresses with proper AddressInfo fields for both external and +// change addresses. +func TestNewDerivedAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-derived") + + // Create account in BIP44 scope. + accountName := "derived-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0044, accountName) + + testCases := []struct { + name string + change bool + expectedBranch uint32 + }{ + { + name: "external address", + change: false, + expectedBranch: 0, + }, + { + name: "change address", + change: true, + expectedBranch: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0044, accountName, tc.change, + ) + + // Verify AddressInfo fields. + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.DerivedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, tc.expectedBranch, info.Branch) + require.GreaterOrEqual(t, info.Index, uint32(0)) + require.Nil(t, info.ScriptPubKey) + require.Nil(t, info.PubKey) + require.False(t, info.IsWatchOnly) + }) + } +} + +// TestNewImportedAddress_NonExistentImportedAccount verifies that calling +// NewImportedAddress when the implicit "imported" account doesn't exist +// returns db.ErrAccountNotFound. This validates that the implicit account +// lookup fails appropriately when the "imported" account has not been created +// in the specified scope. +func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "test-wallet") + + // Attempt to import address when "imported" account doesn't exist. + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + EncryptedPrivateKey: RandomBytes(32), + ScriptPubKey: RandomBytes(32), + } + _, err := store.NewImportedAddress(t.Context(), params) + + // Expect account not found error because an implicit "imported" account + // doesn't exist. + require.ErrorIs(t, err, db.ErrAccountNotFound) +} + +// TestGetAddressSecret_DerivedAddress verifies that calling GetAddressSecret +// on a derived address returns db.ErrSecretNotFound (not ErrAddressNotFound). +// This validates the LEFT JOIN: derived addresses exist in the addresses +// table but have no corresponding row in address_secrets. The query returns a +// row with NULL encrypted_priv_key, and the converter returns ErrSecretNotFound. +func TestGetAddressSecret_DerivedAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "test-wallet") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "test-account") + + params := db.NewDerivedAddressParams{ + WalletID: walletID, + AccountName: "test-account", + Scope: db.KeyScopeBIP0084, + Change: false, + } + addrInfo, err := store.NewDerivedAddress(t.Context(), params, mockDeriveFunc()) + require.NoError(t, err) + + // Attempt to get secret for derived address. + // Derived addresses have no row in address_secrets table. + _, err = store.GetAddressSecret(t.Context(), addrInfo.ID) + + // Expect ErrSecretNotFound (not ErrAddressNotFound) because the + // LEFT JOIN returns a row with NULL encrypted_priv_key. + require.ErrorIs(t, err, db.ErrSecretNotFound) +} + +// TestNewDerivedAddressSequentialIndexes verifies that derived addresses +// receive sequential indexes 0, 1, 2, 3, 4 within the same account and +// branch. +func TestNewDerivedAddressSequentialIndexes(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-sequential-indexes") + + // Create derived account for the test. + accountName := "sequential-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + // Create 5 addresses in external branch and verify sequential indexes. + for i := 0; i < 5; i++ { + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + require.NotNil(t, info) + require.Equal(t, uint32(i), info.Index) + } +} + +// TestListAddressesOrdering verifies that ListAddresses returns addresses +// sorted by index in ascending order, with addresses grouped by branch. +func TestListAddressesOrdering(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-ordering") + + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", false, 3, + ) + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", true, 3, + ) + + addresses, err := store.ListAddresses( + t.Context(), + db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "ordering-account", + }, + ) + + require.NoError(t, err) + require.Len(t, addresses, 6) + + // Separate addresses by branch for verification. + var externalAddrs []db.AddressInfo + var changeAddrs []db.AddressInfo + + for _, addr := range addresses { + if addr.Branch == 0 { + externalAddrs = append(externalAddrs, addr) + } else { + changeAddrs = append(changeAddrs, addr) + } + } + + // Verify external addresses sorted by index. + for i := 1; i < len(externalAddrs); i++ { + require.True( + t, externalAddrs[i-1].Index <= externalAddrs[i].Index, + "external addresses not in order", + ) + } + + // Verify change addresses sorted by index. + for i := 1; i < len(changeAddrs); i++ { + require.True( + t, changeAddrs[i-1].Index <= changeAddrs[i].Index, + "change addresses not in order", + ) + } +} + +// TestNewDerivedAddressErrors verifies that NewDerivedAddress returns +// appropriate errors for invalid parameters, including non-existent +// accounts, unknown key scopes, and empty account names. +func TestNewDerivedAddressErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-new-derived-address-errors") + + tests := []struct { + name string + params db.NewDerivedAddressParams + wantErr error + }{ + { + name: "non-existent account", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "non-existent", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "empty account name", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "unknown key scope returns account not found", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScope{ + Purpose: 999, + Coin: 999, + }, + AccountName: "any-name", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + info, err := store.NewDerivedAddress(t.Context(), tc.params, mockDeriveFunc()) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, info) + }) + } +} + +// TestNewDerivedAddressConcurrent verifies that concurrent address +// creation produces unique sequential indexes without conflicts. +func TestNewDerivedAddressConcurrent(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "concurrent-wallet") + + accountName := "concurrent-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + const workers = 20 + results := make([]db.AddressInfo, workers) + var wg sync.WaitGroup + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + deriveFn := mockDeriveFunc() + + for i := range workers { + wg.Add(1) + go func(i int) { + defer wg.Done() + info, err := store.NewDerivedAddress( + ctx, db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + }, deriveFn, + ) + require.NoError(t, err) + results[i] = *info + }(i) + } + + wg.Wait() + + // Verify all indexes are unique and sequential. + indexes := make([]uint32, workers) + for i, addr := range results { + indexes[i] = addr.Index + } + + sort.Slice(indexes, func(i, j int) bool { + return indexes[i] < indexes[j] + }) + + for i := range workers { + require.Equal(t, uint32(i), indexes[i]) + } +} + +// TestNewDerivedAddressBranchIsolation verifies that external (branch 0) +// and change (branch 1) addresses maintain independent sequential index +// counters within the same account. +func TestNewDerivedAddressBranchIsolation(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-branch-isolation") + + // Create derived account for the test. + accountName := "branch-isolation-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + // Create addresses alternating between branches: + // external-0, change-0, external-1, change-1, external-2, change-2. + var externalAddrs []db.AddressInfo + var changeAddrs []db.AddressInfo + + for i := 0; i < 3; i++ { + // Create external address (branch 0). + extInfo := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + externalAddrs = append(externalAddrs, *extInfo) + + // Create change address (branch 1). + chgInfo := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, + ) + changeAddrs = append(changeAddrs, *chgInfo) + } + + // Verify external addresses have indexes 0, 1, 2. + for i, addr := range externalAddrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + } + + // Verify change addresses have indexes 0, 1, 2. + for i, addr := range changeAddrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(1), addr.Branch) + } +} + +// TestNewDerivedAddressAccountKeyCounts verifies that account key counts are +// derived from the next index counters for both external and internal branches. +func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-account-key-counts") + + accountName := "counted-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, 3, + ) + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, 2, + ) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(3), account.ExternalKeyCount) + require.Equal(t, uint32(2), account.InternalKeyCount) + require.Zero(t, account.ImportedKeyCount) +} + +// TestNewDerivedAddressBranchCounters verifies that external and internal +// counters advance independently when new addresses are created. +func TestNewDerivedAddressBranchCounters(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-branch-counters") + + accountName := "branch-counter-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, + ) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(0), account.ExternalKeyCount) + require.Equal(t, uint32(1), account.InternalKeyCount) + + newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(1), account.ExternalKeyCount) + require.Equal(t, uint32(1), account.InternalKeyCount) +} + +// TestNewDerivedAddressMaxIndex verifies that addresses can be created +// up to the maximum index (math.MaxUint32), but the next address creation +// fails due to overflow. +func TestNewDerivedAddressMaxIndex(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-index") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, "max-acct") + + // Insert address at MaxUint32 - 1 + CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) + + // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 + UpdateAccountNextExternalIndex(t, queries, accountID, math.MaxUint32) + + // This should succeed with address index = MaxUint32. + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "max-acct", false, + ) + require.Equal(t, uint32(math.MaxUint32), info.Index) + + // This should fail; the next allocation would overflow + // uint32. + _, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "max-acct", + Change: false, + }, mockDeriveFunc(), + ) + require.Error(t, err) +} + +// TestNewDerivedAddressMaxIndexInternal verifies that internal addresses can be +// created up to the maximum index (math.MaxUint32), but the next address +// creation fails due to overflow. +func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-index-internal") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, "max-acct") + + // Insert address at MaxUint32 - 1 in the internal branch. + CreateAddressWithIndex(t, queries, accountID, 1, math.MaxUint32-1) + + // Set the internal counter to MaxUint32 so the next allocation gives us + // MaxUint32. + UpdateAccountNextInternalIndex(t, queries, accountID, math.MaxUint32) + + // This should succeed with address index = MaxUint32. + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "max-acct", true, + ) + require.Equal(t, uint32(math.MaxUint32), info.Index) + + // This should fail; the next allocation would overflow uint32. + _, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "max-acct", + Change: true, + }, mockDeriveFunc(), + ) + require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) +} diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 6fc07f9446..ed00da6f62 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -47,6 +47,55 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, require.NoError(t, err) } +// CreateAddressWithIndex creates a derived address with a specific address +// index. Used to test address index overflow without creating billions of +// addresses. +func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, branch uint32, index uint32) { + t.Helper() + + _, err := queries.CreateDerivedAddress( + t.Context(), sqlcpg.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: RandomBytes(20), + TypeID: int16(db.WitnessPubKey), + AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, + PubKey: nil, + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextExternalIndex updates the account's external index counter. +func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, nextIndex uint32) { + t.Helper() + + err := queries.UpdateAccountNextExternalIndex( + t.Context(), sqlcpg.UpdateAccountNextExternalIndexParams{ + ID: accountID, + NextExternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextInternalIndex updates the account's internal index counter. +func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, nextIndex uint32) { + + t.Helper() + + err := queries.UpdateAccountNextInternalIndex( + t.Context(), sqlcpg.UpdateAccountNextInternalIndexParams{ + ID: accountID, + NextInternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, walletID uint32, scope db.KeyScope) int64 { @@ -63,3 +112,42 @@ func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, return row.ID } + +// GetAccountID retrieves the account ID for a given scope and account name. +func GetAccountID(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, accountName string) int64 { + t.Helper() + + row, err := queries.GetAccountByScopeAndName( + t.Context(), + sqlcpg.GetAccountByScopeAndNameParams{ + ScopeID: scopeID, + AccountName: accountName, + }, + ) + require.NoError(t, err) + + return row.ID +} + +func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, + walletID uint32) int64 { + t.Helper() + + addr, err := queries.GetAddressByScriptPubKey( + t.Context(), sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: scriptPubKey, + WalletID: int64(walletID), + }, + ) + require.NoError(t, err) + + return addr.ID +} + +func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, + addressID int64) (sqlcpg.GetAddressSecretRow, error) { + t.Helper() + + return queries.GetAddressSecret(t.Context(), addressID) +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 3d5dfcf676..e0c68134fd 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -47,6 +47,55 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, require.NoError(t, err) } +// CreateAddressWithIndex creates a derived address with a specific address +// index. Used to test address index overflow without creating billions of +// addresses. +func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, branch uint32, index uint32) { + t.Helper() + + _, err := queries.CreateDerivedAddress( + t.Context(), sqlcsqlite.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: RandomBytes(20), + TypeID: int64(db.WitnessPubKey), + AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, + PubKey: nil, + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextExternalIndex updates the account's external index counter. +func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, nextIndex uint32) { + t.Helper() + + err := queries.UpdateAccountNextExternalIndex( + t.Context(), sqlcsqlite.UpdateAccountNextExternalIndexParams{ + ID: accountID, + NextExternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextInternalIndex updates the account's internal index counter. +func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, nextIndex uint32) { + + t.Helper() + + err := queries.UpdateAccountNextInternalIndex( + t.Context(), sqlcsqlite.UpdateAccountNextInternalIndexParams{ + ID: accountID, + NextInternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, walletID uint32, scope db.KeyScope) int64 { @@ -63,3 +112,42 @@ func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, return row.ID } + +// GetAccountID retrieves the account ID for a given scope and account name. +func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, accountName string) int64 { + t.Helper() + + row, err := queries.GetAccountByScopeAndName( + t.Context(), + sqlcsqlite.GetAccountByScopeAndNameParams{ + ScopeID: scopeID, + AccountName: accountName, + }, + ) + require.NoError(t, err) + + return row.ID +} + +func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, + scriptPubKey []byte, walletID uint32) int64 { + t.Helper() + + addr, err := queries.GetAddressByScriptPubKey( + t.Context(), sqlcsqlite.GetAddressByScriptPubKeyParams{ + ScriptPubKey: scriptPubKey, + WalletID: int64(walletID), + }, + ) + require.NoError(t, err) + + return addr.ID +} + +func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, + addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { + t.Helper() + + return queries.GetAddressSecret(t.Context(), addressID) +} From bc187a7e477ea751cc2da16c034107c1f177a935 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 02:08:51 -0300 Subject: [PATCH 078/695] wallet: limit parallel itests for pg db --- wallet/internal/db/itest/pg_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index e93c96a87d..f5f5bd0cff 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -23,6 +23,9 @@ import ( ) var ( + // Limit concurrent database creation to avoid exhausting connections. + pgDBSemaphore = make(chan struct{}, 4) + // Shared container instance, reused across tests for performance. // This is safe to use concurrently because we only share the container // and not the database inside it. Each test gets its own database. @@ -134,6 +137,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { t.Helper() ctx := t.Context() + // Acquire a semaphore slot to limit concurrent database creation and + // parallel test execution that depends on it. + pgDBSemaphore <- struct{}{} + defer func() { + <-pgDBSemaphore + }() + container, err := GetPostgresContainer(ctx) require.NoError(t, err, "failed to get postgres container") @@ -145,12 +155,6 @@ func NewPostgresDB(t *testing.T) *sql.DB { require.NoError(t, err, "failed to open admin connection") require.NotNil(t, adminDB, "admin connection is nil") - // Close the connection to avoid leaking an idle connection during tests. - // The container is reused across all tests, so we explicitly clean this up. - t.Cleanup(func() { - _ = adminDB.Close() - }) - // Create a database name based on the test name. dbName := sanitizedPgDBName(t) @@ -159,6 +163,10 @@ func NewPostgresDB(t *testing.T) *sql.DB { _, err = adminDB.ExecContext(ctx, createDBStmt) require.NoError(t, err, "failed to create test database") + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + _ = adminDB.Close() + // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) From e31779490890ab11b6c2b0bb7fe284ecf976e4a5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 14:29:41 -0300 Subject: [PATCH 079/695] wallet: fix ScriptPubKey comments --- wallet/internal/db/addresses_common.go | 17 +++++++++-------- wallet/internal/db/data_types.go | 7 ++----- wallet/internal/db/itest/address_store_test.go | 2 +- .../migrations/postgres/000006_addresses.up.sql | 13 ++++--------- .../migrations/sqlite/000006_addresses.up.sql | 13 ++++--------- 5 files changed, 20 insertions(+), 32 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 9cb9884f1f..d767faf4a0 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -461,14 +461,15 @@ func createDerivedAddress[T any](ctx context.Context, } return &AddressInfo{ - ID: id, - AccountID: convertedAcctID, - AddrType: addrType, - CreatedAt: rowCreatedAt(row), - Origin: DerivedAccount, - Branch: branch, - Index: index, - IsWatchOnly: false, + ID: id, + AccountID: convertedAcctID, + AddrType: addrType, + CreatedAt: rowCreatedAt(row), + Origin: DerivedAccount, + Branch: branch, + Index: index, + ScriptPubKey: scriptPubKey, + IsWatchOnly: false, }, nil } diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 227a35be56..280e3fed33 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -589,8 +589,7 @@ type AddressInfo struct { // addresses. Index uint32 - // ScriptPubKey is the script pubkey (plaintext). Zero value for - // derived addresses. + // ScriptPubKey is the script pubkey (plaintext). ScriptPubKey []byte // PubKey is the public key (plaintext). Zero value for derived @@ -681,9 +680,7 @@ type GetAddressQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // ScriptPubKey is the script pubkey. If provided, the query will be - // performed using this value. Only applicable to imported addresses, - // as derived addresses have NULL script pubkeys. + // ScriptPubKey is the script pubkey to be fetched. ScriptPubKey []byte } diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 8beccd5cf4..485d37806a 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -680,7 +680,7 @@ func TestNewDerivedAddress(t *testing.T) { require.NotZero(t, info.CreatedAt) require.Equal(t, tc.expectedBranch, info.Branch) require.GreaterOrEqual(t, info.Index, uint32(0)) - require.Nil(t, info.ScriptPubKey) + require.NotNil(t, info.ScriptPubKey) require.Nil(t, info.PubKey) require.False(t, info.IsWatchOnly) }) diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 639d62150d..2d83ca3242 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -55,18 +55,13 @@ ON addresses (account_id, address_branch, address_index) WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique partial index to prevent duplicate script_pub_key within the same --- account. Only enforced when script_pub_key is non-NULL (imported addresses). --- Derived addresses are excluded from this constraint. +-- Unique index to prevent duplicate script_pub_key within the same account. CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key) -WHERE script_pub_key IS NOT NULL; +ON addresses (account_id, script_pub_key); -- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. -CREATE INDEX idx_addresses_script_pub_key -ON addresses (script_pub_key) -WHERE script_pub_key IS NOT NULL; +-- Used by GetAddressByScriptPubKey. +CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 3d3b6e50a5..3c541b756f 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -56,18 +56,13 @@ WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique partial index to prevent duplicate script_pub_key within the same --- account. Only enforced when script_pub_key is non-NULL (imported addresses). --- Derived addresses are excluded from this constraint. +-- Unique index to prevent duplicate script_pub_key within the same account. CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key) -WHERE script_pub_key IS NOT NULL; +ON addresses (account_id, script_pub_key); -- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. -CREATE INDEX idx_addresses_script_pub_key -ON addresses (script_pub_key) -WHERE script_pub_key IS NOT NULL; +-- Used by GetAddressByScriptPubKey. +CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. From ce10c14a8d0ba058e54f9f576ff5fab3d30219c1 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 14:55:13 -0300 Subject: [PATCH 080/695] wallet: add db checks on address index and branch --- .../db/migrations/postgres/000005_accounts.up.sql | 6 ++++++ .../db/migrations/postgres/000006_addresses.up.sql | 10 ++++++++++ .../db/migrations/sqlite/000005_accounts.up.sql | 6 ++++++ .../db/migrations/sqlite/000006_addresses.up.sql | 10 ++++++++++ 4 files changed, 32 insertions(+) diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index cfe297ce26..1687b3f997 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -70,6 +70,12 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index BIGINT NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. + CHECK (next_external_index >= 0), + + -- Internal derivation index must be non-negative. + CHECK (next_internal_index >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 2d83ca3242..4abffac20b 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -38,6 +38,16 @@ CREATE TABLE addresses ( -- Timestamp when the address was created. Automatically set by the database. created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Branch and index are set together for HD-derived addresses and both + -- NULL for imported addresses. + CHECK ((address_branch IS NULL) = (address_index IS NULL)), + + -- Branch must be a BIP44 branch number when set. + CHECK (address_branch IS NULL OR address_branch IN (0, 1)), + + -- Address index must be non-negative when set. + CHECK (address_index IS NULL OR address_index >= 0), + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure -- that the account cannot be deleted if addresses still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 399104ce8f..03ccdce2d3 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -70,6 +70,12 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index INTEGER NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. + CHECK (next_external_index >= 0), + + -- Internal derivation index must be non-negative. + CHECK (next_internal_index >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 3c541b756f..23021a80c8 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -38,6 +38,16 @@ CREATE TABLE addresses ( -- Timestamp when the address was created. Automatically set by the database. created_at DATETIME NOT NULL DEFAULT current_timestamp, + -- Branch and index are set together for HD-derived addresses and both + -- NULL for imported addresses. + CHECK ((address_branch IS NULL) = (address_index IS NULL)), + + -- Branch must be a BIP44 branch number when set. + CHECK (address_branch IS NULL OR address_branch IN (0, 1)), + + -- Address index must be non-negative when set. + CHECK (address_index IS NULL OR address_index >= 0), + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure -- that the account cannot be deleted if addresses still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, From 51dfdbfeae13e866da34bda3c2129e44153fb93a Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 17:49:40 -0300 Subject: [PATCH 081/695] wallet: use pk instead of unique index for extension tables like secrets --- .../migrations/postgres/000002_wallets.up.sql | 21 ++++++------------- .../postgres/000004_key_scopes.up.sql | 11 +++------- .../postgres/000005_accounts.up.sql | 9 +++----- .../postgres/000006_addresses.up.sql | 10 +++------ .../migrations/sqlite/000002_wallets.up.sql | 21 ++++++------------- .../sqlite/000004_key_scopes.up.sql | 11 +++------- .../migrations/sqlite/000005_accounts.up.sql | 9 +++----- .../migrations/sqlite/000006_addresses.up.sql | 10 +++------ 8 files changed, 30 insertions(+), 72 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index c6af352892..65676ee7c3 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -38,9 +38,9 @@ CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Watch-only wallets may have no corresponding row in this table or have all -- private key fields with no data. CREATE TABLE wallet_secrets ( - -- Reference to the wallet these secrets belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id BIGINT NOT NULL, + -- Reference to the wallet these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id BIGINT PRIMARY KEY, -- Params to derive the private master key. NULL for watch-only wallets. master_priv_params BYTEA, @@ -61,17 +61,13 @@ CREATE TABLE wallet_secrets ( FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); --- Enforces one-to-one relationship: each wallet has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); - -- Wallet Sync States table to store the synchronization state of each wallet. -- This is kept separate from the wallets table to avoid write amplification on -- frequently updated sync data. Each wallet has exactly one sync state record. CREATE TABLE wallet_sync_states ( - -- Reference to the wallet this sync state belongs to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id BIGINT NOT NULL, + -- Reference to the wallet this sync state belongs to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id BIGINT PRIMARY KEY, -- Current sync status of the wallet (references blocks table). NULL for wallets -- that haven't synced any blocks yet. @@ -102,8 +98,3 @@ CREATE TABLE wallet_sync_states ( FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each wallet has exactly one sync state record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet -ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index 1162ebd008..5386d22855 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -46,9 +46,9 @@ ON key_scopes (wallet_id, purpose, coin_type); -- Watch-only scopes may have no corresponding row in this table or have NULL -- encrypted_coin_priv_key. CREATE TABLE key_scope_secrets ( - -- Reference to the key scope these keys belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - scope_id BIGINT NOT NULL, + -- Reference to the key scope these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + scope_id BIGINT PRIMARY KEY, -- Encrypted key used to derive private keys for this scope. -- NULL for watch-only key scopes. @@ -58,8 +58,3 @@ CREATE TABLE key_scope_secrets ( -- that the key scope cannot be deleted if secrets still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each key scope has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_key_scope_secrets_scope -ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index 1687b3f997..c10eda819b 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -102,8 +102,9 @@ ON accounts (scope_id, account_name); -- Account Secrets table to hold encrypted account-level secrets. CREATE TABLE account_secrets ( - -- Reference to the account these keys belong to. - account_id BIGINT NOT NULL, + -- Reference to the account these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + account_id BIGINT PRIMARY KEY, -- Encrypted private key for the account. Watch-only accounts may have -- no row in this table. @@ -113,7 +114,3 @@ CREATE TABLE account_secrets ( -- that the account cannot be deleted if secrets still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between account and its secrets. -CREATE UNIQUE INDEX uidx_account_secrets_account -ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 4abffac20b..f942265e2e 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -77,8 +77,9 @@ CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. CREATE TABLE address_secrets ( - -- Reference to the address these secrets belong to. - address_id BIGINT NOT NULL, + -- Reference to the address these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + address_id BIGINT PRIMARY KEY, -- Encrypted private key for imported addresses. NULL for HD-derived -- addresses since their private keys are derived from the account key. @@ -92,8 +93,3 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between address and its --- secrets. -CREATE UNIQUE INDEX uidx_address_secrets_address -ON address_secrets (address_id); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 253849cfb7..db2db83fc3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -38,9 +38,9 @@ CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Watch-only wallets may have no corresponding row in this table or have all -- private key fields with no data. CREATE TABLE wallet_secrets ( - -- Reference to the wallet these secrets belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id INTEGER NOT NULL, + -- Reference to the wallet these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id INTEGER PRIMARY KEY, -- Params to derive the private master key. NULL for watch-only wallets. master_priv_params BLOB, @@ -61,17 +61,13 @@ CREATE TABLE wallet_secrets ( FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); --- Enforces one-to-one relationship: each wallet has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); - -- Wallet Sync States table to store the synchronization state of each wallet. -- This is kept separate from the wallets table to avoid write amplification on -- frequently updated sync data. Each wallet has exactly one sync state record. CREATE TABLE wallet_sync_states ( - -- Reference to the wallet this sync state belongs to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id INTEGER NOT NULL, + -- Reference to the wallet this sync state belongs to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id INTEGER PRIMARY KEY, -- Current sync status of the wallet (references blocks table). NULL for wallets -- that haven't synced any blocks yet. @@ -102,8 +98,3 @@ CREATE TABLE wallet_sync_states ( FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each wallet has exactly one sync state record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet -ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 702758d4ab..ade3e3ef78 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -46,9 +46,9 @@ ON key_scopes (wallet_id, purpose, coin_type); -- Watch-only scopes may have no corresponding row in this table or have NULL -- encrypted_coin_priv_key. CREATE TABLE key_scope_secrets ( - -- Reference to the key scope these keys belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - scope_id INTEGER NOT NULL, + -- Reference to the key scope these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + scope_id INTEGER PRIMARY KEY, -- Encrypted key used to derive private keys for this scope. -- NULL for watch-only key scopes. @@ -58,8 +58,3 @@ CREATE TABLE key_scope_secrets ( -- that the key scope cannot be deleted if secrets still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each key scope has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_key_scope_secrets_scope -ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 03ccdce2d3..34c15e320a 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -102,8 +102,9 @@ ON accounts (scope_id, account_name); -- Account Secrets table to hold encrypted account-level secrets. CREATE TABLE account_secrets ( - -- Reference to the account these keys belong to. - account_id INTEGER NOT NULL, + -- Reference to the account these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + account_id INTEGER PRIMARY KEY, -- Encrypted private key for the account. Watch-only accounts may have -- no row in this table. @@ -113,7 +114,3 @@ CREATE TABLE account_secrets ( -- that the account cannot be deleted if secrets still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between account and its secrets. -CREATE UNIQUE INDEX uidx_account_secrets_account -ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 23021a80c8..e7ccfd6f52 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -78,8 +78,9 @@ CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. CREATE TABLE address_secrets ( - -- Reference to the address these secrets belong to. - address_id INTEGER NOT NULL, + -- Reference to the address these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + address_id INTEGER PRIMARY KEY, -- Encrypted private key for imported addresses. NULL for HD-derived -- addresses since their private keys are derived from the account key. @@ -93,8 +94,3 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between address and its --- secrets. -CREATE UNIQUE INDEX uidx_address_secrets_address -ON address_secrets (address_id); From 2c47315ec8100e7cf04d53a5e60c5d73b575a08f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 18:13:11 -0300 Subject: [PATCH 082/695] wallet: remove dead code --- wallet/internal/db/safecasting.go | 11 ------ wallet/internal/db/safecasting_test.go | 51 -------------------------- 2 files changed, 62 deletions(-) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 8f40e43c69..499c29fdad 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -39,17 +39,6 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } -// int64ToInt16 safely casts an int64 to an int16, returning an error -// if the value is out of range. -func int64ToInt16(v int64) (int16, error) { - if v < math.MinInt16 || v > math.MaxInt16 { - return 0, fmt.Errorf("could not cast %d to int16: %w", v, - ErrCastingOverflow) - } - - return int16(v), nil -} - // int64ToUint8 safely casts an int64 to an uint8, returning an error // if the value is out of range. func int64ToUint8(v int64) (uint8, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 83e568af3a..ec4b4d748a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -200,57 +200,6 @@ func TestInt16ToUint8(t *testing.T) { } } -// TestInt64ToInt16 checks that an int64 value is converted to int16 only -// when it fits within the signed 16 bit range. It should fail loudly for -// any value outside those limits. -func TestInt64ToInt16(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - val int64 - want int16 - wantErr bool - }{ - { - name: "min int16", - val: int64(math.MinInt16), - want: math.MinInt16, - }, - { - name: "max int16", - val: int64(math.MaxInt16), - want: math.MaxInt16, - }, - {name: "zero", val: 0, want: 0}, - { - name: "below min", - val: int64(math.MinInt16) - 1, - wantErr: true, - }, - { - name: "above max", - val: int64(math.MaxInt16) + 1, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := int64ToInt16(tc.val) - if tc.wantErr { - require.ErrorIs(t, err, ErrCastingOverflow) - return - } - - require.NoError(t, err) - require.Equal(t, tc.want, got) - }) - } -} - // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From 0d640f5fff37034a4b8332e38d7204b6b4e59fca Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:04:19 -0300 Subject: [PATCH 083/695] wallet: generalize AddressSecretRow conversion --- wallet/internal/db/addresses_common.go | 36 ++++++++++++++++++++++++++ wallet/internal/db/addresses_pg.go | 29 +++++---------------- wallet/internal/db/addresses_sqlite.go | 25 ++++-------------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index d767faf4a0..2c9bbf8870 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -158,6 +158,42 @@ type addressInfoRow[TypeID, OriginIDType any] struct { IDToOrigin func(OriginIDType) (AccountOrigin, error) } +// addressSecretRow captures fields shared by address secret row types across +// backends. +type addressSecretRow struct { + // AddressID is the database unique identifier for the address. + AddressID int64 + + // EncryptedPrivKey is the encrypted private key for imported addresses. + EncryptedPrivKey []byte + + // EncryptedScript is the encrypted script for script-based addresses. + EncryptedScript []byte +} + +// addressSecretRowToSecret converts raw secret row fields into an AddressSecret +// with validation and ID conversion. +func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + // convertAddressIDs converts database IDs to their respective uint32 values // with error handling. func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 76c3d42334..cb0f14b722 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - "fmt" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -240,30 +239,16 @@ func importedAddressRowCreatedAtPg( return row.CreatedAt } -// pgAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the -// db.AddressSecret type used by the wallet, handling null value conversions. -// Returns ErrSecretNotFound if the address exists but has no secret. -func pgAddressSecretRowToSecret(row sqlcpg.GetAddressSecretRow) (*AddressSecret, - error) { - - hasKey := len(row.EncryptedPrivKey) > 0 - hasScript := len(row.EncryptedScript) > 0 - - if !hasKey && !hasScript { - return nil, fmt.Errorf("address %d: %w", row.AddressID, - ErrSecretNotFound) - } +// pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an +// AddressSecret struct. +func pgAddressSecretRowToSecret( + row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { - addrID, err := int64ToUint32(row.AddressID) - if err != nil { - return nil, fmt.Errorf("address ID: %w", err) - } - - return &AddressSecret{ - AddressID: addrID, + return addressSecretRowToSecret(addressSecretRow{ + AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, - }, nil + }) } // pgAddressInfoRow is a type constraint that unifies all PostgreSQL address diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index f89ea9143c..7a36d12434 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - "fmt" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -243,30 +242,16 @@ func insertAddressSecretParamsSQLite(addressID int64, } } -// sqliteAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the -// db.AddressSecret type used by the wallet, handling null value conversions. -// Returns ErrSecretNotFound if the secret is missing. +// sqliteAddressSecretRowToSecret converts a SQLite address secret row to an +// AddressSecret struct. func sqliteAddressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { - hasKey := len(row.EncryptedPrivKey) > 0 - hasScript := len(row.EncryptedScript) > 0 - - if !hasKey && !hasScript { - return nil, fmt.Errorf("address %d: %w", row.AddressID, - ErrSecretNotFound) - } - - addrID, err := int64ToUint32(row.AddressID) - if err != nil { - return nil, fmt.Errorf("address ID: %w", err) - } - - return &AddressSecret{ - AddressID: addrID, + return addressSecretRowToSecret(addressSecretRow{ + AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, - }, nil + }) } // sqliteAddressInfoRow is a type constraint union that represents all SQLite From c912ac652a8e37dab0a961e937ab64f16709582d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:13:16 -0300 Subject: [PATCH 084/695] wallet: require ErrMaxAddressIndexReached on test --- wallet/internal/db/itest/address_store_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 485d37806a..aeb50666b5 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1078,7 +1078,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { Change: false, }, mockDeriveFunc(), ) - require.Error(t, err) + require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) } // TestNewDerivedAddressMaxIndexInternal verifies that internal addresses can be From d3a04beece59ab8d5c767db2a49ab10406f7506d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:53:30 -0300 Subject: [PATCH 085/695] wallet: use raw sql for test specific queries --- .../internal/db/itest/address_store_test.go | 8 ++--- wallet/internal/db/itest/fixtures_pg_test.go | 22 ++++++------ .../internal/db/itest/fixtures_sqlite_test.go | 22 ++++++------ wallet/internal/db/itest/pg_test.go | 17 +++++++-- wallet/internal/db/itest/sqlite_test.go | 17 +++++++-- .../internal/db/queries/postgres/accounts.sql | 14 -------- .../internal/db/queries/sqlite/accounts.sql | 14 -------- .../internal/db/sqlc/postgres/accounts.sql.go | 36 ------------------- wallet/internal/db/sqlc/postgres/db.go | 20 ----------- wallet/internal/db/sqlc/postgres/querier.go | 6 ---- .../internal/db/sqlc/sqlite/accounts.sql.go | 36 ------------------- wallet/internal/db/sqlc/sqlite/db.go | 20 ----------- wallet/internal/db/sqlc/sqlite/querier.go | 6 ---- 13 files changed, 52 insertions(+), 186 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index aeb50666b5..cd2744cc21 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1049,7 +1049,7 @@ func TestNewDerivedAddressBranchCounters(t *testing.T) { func TestNewDerivedAddressMaxIndex(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store, queries, dbConn := NewTestStoreWithDB(t) walletID := newWallet(t, store, "wallet-max-index") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1060,7 +1060,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 - UpdateAccountNextExternalIndex(t, queries, accountID, math.MaxUint32) + UpdateAccountNextExternalIndex(t, dbConn, accountID, math.MaxUint32) // This should succeed with address index = MaxUint32. info := newDerivedAddress( @@ -1087,7 +1087,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store, queries, dbConn := NewTestStoreWithDB(t) walletID := newWallet(t, store, "wallet-max-index-internal") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1099,7 +1099,7 @@ func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { // Set the internal counter to MaxUint32 so the next allocation gives us // MaxUint32. - UpdateAccountNextInternalIndex(t, queries, accountID, math.MaxUint32) + UpdateAccountNextInternalIndex(t, dbConn, accountID, math.MaxUint32) // This should succeed with address index = MaxUint32. info := newDerivedAddress( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index ed00da6f62..103242c142 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -68,30 +68,28 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, } // UpdateAccountNextExternalIndex updates the account's external index counter. -func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcpg.Queries, +func UpdateAccountNextExternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextExternalIndex( - t.Context(), sqlcpg.UpdateAccountNextExternalIndexParams{ - ID: accountID, - NextExternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_external_index = $1 WHERE id = $2", + int64(nextIndex), accountID, ) require.NoError(t, err) } // UpdateAccountNextInternalIndex updates the account's internal index counter. -func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcpg.Queries, +func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextInternalIndex( - t.Context(), sqlcpg.UpdateAccountNextInternalIndexParams{ - ID: accountID, - NextInternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_internal_index = $1 WHERE id = $2", + int64(nextIndex), accountID, ) require.NoError(t, err) } diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index e0c68134fd..c7cfbbdc73 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -68,30 +68,28 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, } // UpdateAccountNextExternalIndex updates the account's external index counter. -func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcsqlite.Queries, +func UpdateAccountNextExternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextExternalIndex( - t.Context(), sqlcsqlite.UpdateAccountNextExternalIndexParams{ - ID: accountID, - NextExternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_external_index = ? WHERE id = ?", + int64(nextIndex), accountID, ) require.NoError(t, err) } // UpdateAccountNextInternalIndex updates the account's internal index counter. -func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcsqlite.Queries, +func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextInternalIndex( - t.Context(), sqlcsqlite.UpdateAccountNextInternalIndexParams{ - ID: accountID, - NextInternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_internal_index = ? WHERE id = ?", + int64(nextIndex), accountID, ) require.NoError(t, err) } diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f5f5bd0cff..ac892a4b56 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -188,9 +188,11 @@ func NewPostgresDB(t *testing.T) *sql.DB { return dbConn } -// NewTestStore creates a PostgreSQL wallet store and returns it along with the -// underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { +// NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the +// raw sql.DB for fixture-level direct SQL setup. +func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, + *sql.DB) { + t.Helper() dbConn := NewPostgresDB(t) @@ -200,5 +202,14 @@ func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { queries := sqlcpg.New(dbConn) + return store, queries, dbConn +} + +// NewTestStore creates a PostgreSQL wallet store and returns it with queries. +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { + t.Helper() + + store, queries, _ := NewTestStoreWithDB(t) + return store, queries } diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 666b32c444..975872fd13 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -50,9 +50,11 @@ func NewSQLiteDB(t *testing.T) *sql.DB { return dbConn } -// NewTestStore creates the SQLite wallet store and returns it along with the -// underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { +// NewTestStoreWithDB creates a SQLite wallet store and also returns the raw +// sql.DB for fixture-level direct SQL setup. +func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, + *sql.DB) { + t.Helper() dbConn := NewSQLiteDB(t) @@ -62,5 +64,14 @@ func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { queries := sqlcsqlite.New(dbConn) + return store, queries, dbConn +} + +// NewTestStore creates the SQLite wallet store and returns it with queries. +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { + t.Helper() + + store, queries, _ := NewTestStoreWithDB(t) + return store, queries } diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index afa4e885e9..2d4ca4169b 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -335,17 +335,3 @@ UPDATE accounts SET next_internal_index = next_internal_index + 1 WHERE id = $1 RETURNING (next_internal_index - 1)::BIGINT AS address_index; - --- name: UpdateAccountNextExternalIndex :exec --- Updates the next_external_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_external_index = $2 -WHERE id = $1; - --- name: UpdateAccountNextInternalIndex :exec --- Updates the next_internal_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_internal_index = $2 -WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index cea8dc577a..a8110a1fad 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -306,17 +306,3 @@ UPDATE accounts SET next_internal_index = next_internal_index + 1 WHERE id = ? RETURNING next_internal_index - 1 AS address_index; - --- name: UpdateAccountNextExternalIndex :exec --- Updates the next_external_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_external_index = ?2 -WHERE id = ?1; - --- name: UpdateAccountNextInternalIndex :exec --- Updates the next_internal_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_internal_index = ?2 -WHERE id = ?1; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b3bd193e1b..ed6141a79f 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -934,39 +934,3 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } - -const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec -UPDATE accounts -SET next_external_index = $2 -WHERE id = $1 -` - -type UpdateAccountNextExternalIndexParams struct { - ID int64 - NextExternalIndex int64 -} - -// Updates the next_external_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) - return err -} - -const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec -UPDATE accounts -SET next_internal_index = $2 -WHERE id = $1 -` - -type UpdateAccountNextInternalIndexParams struct { - ID int64 - NextInternalIndex int64 -} - -// Updates the next_internal_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) - return err -} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 07cbfc7f13..17936fad3f 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -156,12 +156,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } - if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) - } - if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) - } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -393,16 +387,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } - if q.updateAccountNextExternalIndexStmt != nil { - if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) - } - } - if q.updateAccountNextInternalIndexStmt != nil { - if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) - } - } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -496,8 +480,6 @@ type Queries struct { lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt - updateAccountNextExternalIndexStmt *sql.Stmt - updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -550,8 +532,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, - updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, - updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 54f20c5b51..b0bf31677f 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -129,12 +129,6 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) - // Updates the next_external_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error - // Updates the next_internal_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 51c363249e..9131c8e9ed 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -900,39 +900,3 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } - -const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec -UPDATE accounts -SET next_external_index = ?2 -WHERE id = ?1 -` - -type UpdateAccountNextExternalIndexParams struct { - ID int64 - NextExternalIndex int64 -} - -// Updates the next_external_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) - return err -} - -const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec -UPDATE accounts -SET next_internal_index = ?2 -WHERE id = ?1 -` - -type UpdateAccountNextInternalIndexParams struct { - ID int64 - NextInternalIndex int64 -} - -// Updates the next_internal_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) - return err -} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 25ad7c5f16..b53304ffad 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -153,12 +153,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } - if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) - } - if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) - } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -385,16 +379,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } - if q.updateAccountNextExternalIndexStmt != nil { - if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) - } - } - if q.updateAccountNextInternalIndexStmt != nil { - if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) - } - } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -487,8 +471,6 @@ type Queries struct { listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt - updateAccountNextExternalIndexStmt *sql.Stmt - updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -540,8 +522,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, - updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, - updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 3eba8e332e..4968d0ed5b 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -104,12 +104,6 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) - // Updates the next_external_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error - // Updates the next_internal_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From 0a3e00cb56869574fe8e8e7d2b732a4e0b4e10a7 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 17:42:11 -0300 Subject: [PATCH 086/695] wallet: add count column for imported addresses --- .../internal/db/itest/address_store_test.go | 129 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 33 +++++ .../internal/db/itest/fixtures_sqlite_test.go | 33 +++++ .../postgres/000005_accounts.up.sql | 6 + .../postgres/000006_addresses.up.sql | 36 +++++ .../migrations/sqlite/000005_accounts.up.sql | 6 + .../migrations/sqlite/000006_addresses.up.sql | 20 +++ .../internal/db/queries/postgres/accounts.sql | 46 ++----- .../internal/db/queries/sqlite/accounts.sql | 46 ++----- .../internal/db/sqlc/postgres/accounts.sql.go | 36 ++--- wallet/internal/db/sqlc/postgres/models.go | 1 + .../internal/db/sqlc/sqlite/accounts.sql.go | 36 ++--- wallet/internal/db/sqlc/sqlite/models.go | 1 + 13 files changed, 311 insertions(+), 118 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index cd2744cc21..01b2453877 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -334,6 +334,135 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { } } +// TestImportedAddressCounterInsertDelete verifies that imported address inserts +// increment the per-account counter and deletes decrement it. +func TestImportedAddressCounterInsertDelete(t *testing.T) { + t.Parallel() + + store, _, dbConn := NewTestStoreWithDB(t) + walletID := newWallet(t, store, "wallet-imported-counter") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + const importedAddrCount = 5 + addressIDs := make([]uint32, 0, importedAddrCount) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) + + for i := 0; i < importedAddrCount; i++ { + info, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + ScriptPubKey: RandomBytes(32), + PubKey: RandomBytes(33), + }, + ) + require.NoError(t, err) + + addressIDs = append(addressIDs, info.ID) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Equal(t, uint32(importedAddrCount), account.ImportedKeyCount) + + for _, addressID := range addressIDs { + MustDeleteAddress(t, dbConn, addressID) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) +} + +// TestImportedAddressCounterConcurrentInsert verifies that concurrent imported +// address inserts correctly update the per-account imported key counter. +func TestImportedAddressCounterConcurrentInsert(t *testing.T) { + t.Parallel() + + store, _, dbConn := NewTestStoreWithDB(t) + walletID := newWallet(t, store, "wallet-imported-counter-concurrent") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + const workers = 20 + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + type insertResult struct { + id uint32 + err error + } + + insertResultChan := make(chan insertResult, workers) + var wg sync.WaitGroup + + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + + info, err := store.NewImportedAddress( + ctx, db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + ScriptPubKey: RandomBytes(32), + PubKey: RandomBytes(33), + }, + ) + if err != nil { + insertResultChan <- insertResult{err: err} + return + } + + insertResultChan <- insertResult{id: info.ID} + }() + } + + wg.Wait() + close(insertResultChan) + + addressIDs := make([]uint32, 0, workers) + for result := range insertResultChan { + require.NoError(t, result.err) + addressIDs = append(addressIDs, result.id) + } + + require.Len(t, addressIDs, workers) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Equal(t, uint32(workers), account.ImportedKeyCount) + + deleteErrChan := make(chan error, workers) + for _, addressID := range addressIDs { + wg.Add(1) + go func() { + defer wg.Done() + deleteErrChan <- deleteAddress(ctx, dbConn, addressID) + }() + } + + wg.Wait() + close(deleteErrChan) + + for err := range deleteErrChan { + require.NoError(t, err) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) +} + // TestNewImportedAddressDuplicate verifies that importing an address with // a duplicate ScriptPubKey fails with a constraint error. func TestNewImportedAddressDuplicate(t *testing.T) { diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 103242c142..210b406cf5 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -3,7 +3,9 @@ package itest import ( + "context" "database/sql" + "fmt" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -149,3 +151,34 @@ func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, return queries.GetAddressSecret(t.Context(), addressID) } + +// MustDeleteAddress deletes an address by ID for test scenarios. +func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { + t.Helper() + + err := deleteAddress(t.Context(), dbConn, addressID) + require.NoError(t, err) +} + +// deleteAddress removes a single address row by ID and validates row count. +func deleteAddress(ctx context.Context, dbConn *sql.DB, + addressID uint32) error { + + result, err := dbConn.ExecContext( + ctx, "DELETE FROM addresses WHERE id = $1", int64(addressID), + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows != 1 { + return fmt.Errorf("expected 1 deleted row, got %d", rows) + } + + return nil +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index c7cfbbdc73..034c30abe3 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -3,7 +3,9 @@ package itest import ( + "context" "database/sql" + "fmt" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -149,3 +151,34 @@ func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, return queries.GetAddressSecret(t.Context(), addressID) } + +// MustDeleteAddress deletes an address by ID for test scenarios. +func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { + t.Helper() + + err := deleteAddress(t.Context(), dbConn, addressID) + require.NoError(t, err) +} + +// deleteAddress removes a single address row by ID and validates row count. +func deleteAddress(ctx context.Context, dbConn *sql.DB, + addressID uint32) error { + + result, err := dbConn.ExecContext( + ctx, "DELETE FROM addresses WHERE id = ?", int64(addressID), + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows != 1 { + return fmt.Errorf("expected 1 deleted row, got %d", rows) + } + + return nil +} diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index c10eda819b..5108396780 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -70,12 +70,18 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index BIGINT NOT NULL DEFAULT 0, + -- Number of imported addresses in this account. + imported_key_count BIGINT NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. CHECK (next_external_index >= 0), -- Internal derivation index must be non-negative. CHECK (next_internal_index >= 0), + -- Imported address counter must be non-negative. + CHECK (imported_key_count >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index f942265e2e..0fa11dbaf1 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -93,3 +93,39 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); + +-- Increments imported_key_count for imported address inserts. +CREATE FUNCTION sync_account_imported_key_count_insert() RETURNS TRIGGER AS $$ +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count + 1 + WHERE id = NEW.account_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to keep imported_key_count accurate for imported address inserts. +CREATE TRIGGER trg_addresses_imported_key_count_insert +AFTER INSERT ON addresses +FOR EACH ROW +WHEN (new.address_branch IS NULL) +EXECUTE FUNCTION sync_account_imported_key_count_insert(); + +-- Decrements imported_key_count for imported address deletes. +CREATE FUNCTION sync_account_imported_key_count_delete() RETURNS TRIGGER AS $$ +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count - 1 + WHERE id = OLD.account_id; + + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to keep imported_key_count accurate for imported address deletes. +CREATE TRIGGER trg_addresses_imported_key_count_delete +AFTER DELETE ON addresses +FOR EACH ROW +WHEN (old.address_branch IS NULL) +EXECUTE FUNCTION sync_account_imported_key_count_delete(); diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 34c15e320a..e99fc90910 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -70,12 +70,18 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index INTEGER NOT NULL DEFAULT 0, + -- Number of imported addresses in this account. + imported_key_count INTEGER NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. CHECK (next_external_index >= 0), -- Internal derivation index must be non-negative. CHECK (next_internal_index >= 0), + -- Imported address counter must be non-negative. + CHECK (imported_key_count >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index e7ccfd6f52..39e5f96d69 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -94,3 +94,23 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); + +-- Increments imported_key_count when a new imported address is inserted. +CREATE TRIGGER trg_addresses_imported_key_count_insert +AFTER INSERT ON addresses +WHEN new.address_branch IS NULL +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count + 1 + WHERE id = new.account_id; +END; + +-- Decrements imported_key_count when an imported address is deleted. +CREATE TRIGGER trg_addresses_imported_key_count_delete +AFTER DELETE ON addresses +WHEN old.address_branch IS NULL +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count - 1 + WHERE id = old.account_id; +END; diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 2d4ca4169b..0a92f1f307 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -88,12 +88,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id; +WHERE a.scope_id = $1 AND a.account_name = $2; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -108,12 +106,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = $1 AND a.account_number = $2 -GROUP BY a.id, ks.id; +WHERE a.scope_id = $1 AND a.account_number = $2; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -128,16 +124,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_name = $4 -GROUP BY a.id, ks.id; + AND a.account_name = $4; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -152,16 +146,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_number = $4 -GROUP BY a.id, ks.id; + AND a.account_number = $4; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -179,12 +171,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.id = $1 -GROUP BY a.id, ks.id; +WHERE a.id = $1; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -200,12 +190,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletScope :many @@ -222,15 +210,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletAndName :many @@ -247,12 +233,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWallet :many @@ -269,12 +253,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index a8110a1fad..ed50418fd2 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -59,12 +59,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id; +WHERE a.scope_id = ? AND a.account_name = ?; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -79,12 +77,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = ? AND a.account_number = ? -GROUP BY a.id, ks.id; +WHERE a.scope_id = ? AND a.account_number = ?; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -99,16 +95,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_name = ? -GROUP BY a.id, ks.id; + AND a.account_name = ?; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -123,16 +117,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_number = ? -GROUP BY a.id, ks.id; + AND a.account_number = ?; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -150,12 +142,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.id = ? -GROUP BY a.id, ks.id; +WHERE a.id = ?; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -171,12 +161,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletScope :many @@ -193,15 +181,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletAndName :many @@ -218,12 +204,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWallet :many @@ -240,12 +224,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index ed6141a79f..894cd597d1 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -184,12 +184,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -243,12 +241,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_number = $2 -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -302,16 +298,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_name = $4 -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -372,16 +366,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_number = $4 -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -445,12 +437,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = $1 -GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -537,12 +527,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -609,12 +597,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -681,12 +667,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -758,15 +742,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3c11ebf44b..bc177a29bf 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -21,6 +21,7 @@ type Account struct { CreatedAt time.Time NextExternalIndex int64 NextInternalIndex int64 + ImportedKeyCount int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 9131c8e9ed..f50130e742 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -182,12 +182,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -241,12 +239,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_number = ? -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -300,16 +296,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_name = ? -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -370,16 +364,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_number = ? -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -443,12 +435,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = ? -GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -535,12 +525,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -607,12 +595,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -679,12 +665,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -756,15 +740,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 8681080041..6352775b72 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -21,6 +21,7 @@ type Account struct { CreatedAt time.Time NextExternalIndex int64 NextInternalIndex int64 + ImportedKeyCount int64 } type AccountOrigin struct { From b09766d3a7d7358d54c006d9166871ed32010305 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 17:47:28 -0300 Subject: [PATCH 087/695] wallet: fix stale comment for CreateDerivedAddress --- wallet/internal/db/queries/postgres/addresses.sql | 3 ++- wallet/internal/db/queries/sqlite/addresses.sql | 3 ++- wallet/internal/db/sqlc/postgres/addresses.sql.go | 3 ++- wallet/internal/db/sqlc/postgres/querier.go | 3 ++- wallet/internal/db/sqlc/sqlite/addresses.sql.go | 3 ++- wallet/internal/db/sqlc/sqlite/querier.go | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index 710a2ed983..ad723709b9 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -44,7 +44,8 @@ WHERE a.id = $1; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. --- The index is allocated separately via GetAndIncrementNextAddressIndex. +-- The index is allocated separately via GetAndIncrementNextExternalIndex +-- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( account_id, script_pub_key, diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index 81799b396a..0428c6d537 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -44,7 +44,8 @@ WHERE a.id = ?; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. --- The index is allocated separately via GetAndIncrementNextAddressIndex. +-- The index is allocated separately via GetAndIncrementNextExternalIndex +-- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( account_id, script_pub_key, diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index d282bfe308..a6c61ad0a1 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -38,7 +38,8 @@ type CreateDerivedAddressRow struct { } // Creates a derived address with the given index and derived data. -// The index is allocated separately via GetAndIncrementNextAddressIndex. +// The index is allocated separately via GetAndIncrementNextExternalIndex +// or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, arg.AccountID, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index b0bf31677f..84f628a8c8 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -20,7 +20,8 @@ type Querier interface { // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a derived address with the given index and derived data. - // The index is allocated separately via GetAndIncrementNextAddressIndex. + // The index is allocated separately via GetAndIncrementNextExternalIndex + // or GetAndIncrementNextInternalIndex. CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index ebae0bb83f..5ce938af6d 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -38,7 +38,8 @@ type CreateDerivedAddressRow struct { } // Creates a derived address with the given index and derived data. -// The index is allocated separately via GetAndIncrementNextAddressIndex. +// The index is allocated separately via GetAndIncrementNextExternalIndex +// or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, arg.AccountID, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 4968d0ed5b..e55e93f16a 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -19,7 +19,8 @@ type Querier interface { // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a derived address with the given index and derived data. - // The index is allocated separately via GetAndIncrementNextAddressIndex. + // The index is allocated separately via GetAndIncrementNextExternalIndex + // or GetAndIncrementNextInternalIndex. CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need From 54ab906ac90c4b45374bec1d7a417884099bf1ed Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:01:57 -0300 Subject: [PATCH 088/695] wallet: add order by on list addresses query --- wallet/internal/db/queries/postgres/addresses.sql | 3 ++- wallet/internal/db/queries/sqlite/addresses.sql | 3 ++- wallet/internal/db/sqlc/postgres/addresses.sql.go | 1 + wallet/internal/db/sqlc/sqlite/addresses.sql.go | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index ad723709b9..c566ac3159 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -92,4 +92,5 @@ INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND acc.account_name = $4; + AND acc.account_name = $4 +ORDER BY a.id; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index 0428c6d537..d195991d7e 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -92,4 +92,5 @@ INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ?; + AND acc.account_name = ? +ORDER BY a.id; diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index a6c61ad0a1..52c0892aa0 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -222,6 +222,7 @@ LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND acc.account_name = $4 +ORDER BY a.id ` type ListAddressesByAccountParams struct { diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index 5ce938af6d..393fdafc4d 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -222,6 +222,7 @@ LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND acc.account_name = ? +ORDER BY a.id ` type ListAddressesByAccountParams struct { From c4fe350633481efcb23b847278cb468e6e84fe07 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:23:37 -0300 Subject: [PATCH 089/695] wallet: move derivation path validation to convertAddressPath --- wallet/internal/db/addresses_common.go | 31 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 2c9bbf8870..204712b022 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -270,9 +270,24 @@ func convertAddressMetadata[TypeID, OriginIDType any]( return addrType, origin, nil } -// convertAddressPath converts BIP44 branch and index values with error -// handling. -func convertAddressPath(branch, index sql.NullInt64) (uint32, uint32, error) { +// convertAddressPath converts BIP44 branch/index values into uint32 fields. +// Imported addresses must have both branch/index unset and return zero values. +// Derived addresses must have both fields set and convertible to uint32. +func convertAddressPath(origin AccountOrigin, branch, + index sql.NullInt64) (uint32, uint32, error) { + + if origin == ImportedAccount { + if branch.Valid || index.Valid { + return 0, 0, errInvalidDerivationPath + } + + return 0, 0, nil + } + + if !branch.Valid || !index.Valid { + return 0, 0, errInvalidDerivationPath + } + addrBranch, err := int64ToUint32(branch.Int64) if err != nil { return 0, 0, fmt.Errorf("address branch: %w", err) @@ -301,16 +316,8 @@ func addressRowToInfo[TypeID, OriginIDType any]( return nil, err } - if origin == DerivedAccount { - if !row.AddressIndex.Valid || - !row.AddressBranch.Valid { - - return nil, errInvalidDerivationPath - } - } - addrBranch, addrIndex, err := convertAddressPath( - row.AddressBranch, row.AddressIndex, + origin, row.AddressBranch, row.AddressIndex, ) if err != nil { return nil, err From 849511c604b1ae4107bd42c6724ca9d68244524c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:52:16 -0300 Subject: [PATCH 090/695] wallet: use smallint for branch index --- wallet/internal/db/addresses_pg.go | 27 ++++++++++---- wallet/internal/db/itest/fixtures_pg_test.go | 4 +- .../postgres/000006_addresses.up.sql | 8 ++-- wallet/internal/db/safecasting.go | 11 ++++++ wallet/internal/db/safecasting_test.go | 37 +++++++++++++++++++ .../db/sqlc/postgres/addresses.sql.go | 6 +-- wallet/internal/db/sqlc/postgres/models.go | 2 +- 7 files changed, 78 insertions(+), 17 deletions(-) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index cb0f14b722..78ca288a0c 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "fmt" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -151,13 +152,20 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + branchNum, err := uint32ToInt16(branch) + if err != nil { + return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( + "address branch: %w", err, + ) + } + return qtx.CreateDerivedAddress( ctx, sqlcpg.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), - AddressBranch: sql.NullInt64{ - Int64: int64(branch), + AddressBranch: sql.NullInt16{ + Int16: branchNum, Valid: true, }, AddressIndex: sql.NullInt64{ @@ -274,12 +282,15 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { HasPrivateKey: base.HasPrivateKey, HasScript: base.HasScript, CreatedAt: base.CreatedAt, - AddressBranch: base.AddressBranch, - AddressIndex: base.AddressIndex, - ScriptPubKey: base.ScriptPubKey, - PubKey: base.PubKey, - IDToAddrType: idToAddressType[int16], - IDToOrigin: idToOrigin[int16], + AddressBranch: sql.NullInt64{ + Int64: int64(base.AddressBranch.Int16), + Valid: base.AddressBranch.Valid, + }, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int16], + IDToOrigin: idToOrigin[int16], }) if err != nil { return nil, err diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 210b406cf5..83b77b09a4 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -53,7 +53,7 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, - accountID int64, branch uint32, index uint32) { + accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( @@ -61,7 +61,7 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), - AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressBranch: sql.NullInt16{Int16: branch, Valid: true}, AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, PubKey: nil, }, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 0fa11dbaf1..3e97d62d35 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -22,9 +22,11 @@ CREATE TABLE addresses ( -- how the address is encoded and how funds can be spent. type_id SMALLINT NOT NULL, - -- Branch number in BIP44 derivation path (typically 0 for external, 1 for - -- internal/change). NULL for imported addresses. - address_branch BIGINT, + -- Branch number in BIP44 derivation path. We currently use only 0 + -- (external) and 1 (internal/change), so SMALLINT is sufficient. This can + -- be widened to BIGINT later with ALTER COLUMN if branch semantics expand. + -- NULL for imported addresses. + address_branch SMALLINT, -- Index number in BIP44 derivation path (sequential counter within each -- branch). NULL for imported addresses. diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 499c29fdad..5393670073 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -72,6 +72,17 @@ func uint32ToInt32(v uint32) (int32, error) { return int32(v), nil } +// uint32ToInt16 safely casts an uint32 to an int16, returning an error +// if the value is out of range. +func uint32ToInt16(v uint32) (int16, error) { + if v > math.MaxInt16 { + return 0, fmt.Errorf("could not cast %d to int16: %w", v, + ErrCastingOverflow) + } + + return int16(v), nil +} + // uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning // an error if the value is out of range. func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index ec4b4d748a..0dd260851f 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -237,6 +237,43 @@ func TestUint32ToInt32(t *testing.T) { } } +// TestUint32ToInt16 checks that an uint32 value is safely converted to int16 +// only when it fits within the signed 16 bit range. It should fail loudly +// for any value that exceeds those limits. +func TestUint32ToInt16(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want int16 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + {name: "max int16", val: math.MaxInt16, want: math.MaxInt16}, + { + name: "overflow", + val: uint32(math.MaxInt16) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToInt16(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToNullInt32 checks that we respect the signed 32 bit limits // before converting an uint32 value into sql.NullInt32. It should fail // loudly when the value is out of range or when valid is false. diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index 52c0892aa0..3d5b0c0777 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -27,7 +27,7 @@ type CreateDerivedAddressParams struct { AccountID int64 ScriptPubKey []byte TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 PubKey []byte } @@ -122,7 +122,7 @@ type GetAddressByScriptPubKeyRow struct { ID int64 AccountID int64 TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 ScriptPubKey []byte PubKey []byte @@ -236,7 +236,7 @@ type ListAddressesByAccountRow struct { ID int64 AccountID int64 TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 ScriptPubKey []byte PubKey []byte diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index bc177a29bf..411c05c8c6 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -39,7 +39,7 @@ type Address struct { AccountID int64 ScriptPubKey []byte TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 PubKey []byte CreatedAt time.Time From dad337aba33ca36c82905ef8c184653ffc80c5a0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 5 Feb 2026 03:31:24 +0800 Subject: [PATCH 091/695] build: fix make lint in git worktrees This commit updates the Makefile to correctly handle git worktrees when running the linter. By default, the linter's diff processor fails in worktrees because the .git file points to a different location. We now detect this and mount the common git directory into the Docker container so that revgrep can access the git history. --- Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 81582e266a..f1eff8611c 100644 --- a/Makefile +++ b/Makefile @@ -30,10 +30,22 @@ ifneq ($(workers),) LINT_WORKERS = --concurrency=$(workers) endif +# Detect if we're in a git worktree. Use git rev-parse --git-common-dir to get +# the path to the main git directory for the linter's diff processor to work +# correctly with the new-from-rev setting. +GIT_COMMON_DIR := $(shell \ + common_dir="$$(git rev-parse --git-common-dir 2>/dev/null)"; \ + if [ "$$common_dir" != ".git" ] && [ -n "$$common_dir" ]; then \ + echo "$$common_dir"; \ + fi) +GIT_VOLUME := $(if $(GIT_COMMON_DIR),-v "$(GIT_COMMON_DIR):$(GIT_COMMON_DIR):ro",) + DOCKER_TOOLS = docker run \ --rm \ -v $(shell bash -c "mkdir -p /tmp/go-build-cache; echo /tmp/go-build-cache"):/root/.cache/go-build \ - -v $$(pwd):/build btcwallet-tools + -v $$(pwd):/build \ + $(GIT_VOLUME) \ + btcwallet-tools SQLFLUFF = docker run \ --rm \ From eac8c0e6cfa5f9bd89f700f6960955e599009a54 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 5 Feb 2026 23:45:31 +0800 Subject: [PATCH 092/695] doc(adr): add adr-0008 integration test framework --- .../adr/0008-integration-test-framework.md | 121 ++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 122 insertions(+) create mode 100644 docs/developer/adr/0008-integration-test-framework.md diff --git a/docs/developer/adr/0008-integration-test-framework.md b/docs/developer/adr/0008-integration-test-framework.md new file mode 100644 index 0000000000..515854d2e4 --- /dev/null +++ b/docs/developer/adr/0008-integration-test-framework.md @@ -0,0 +1,121 @@ +# ADR 0008: Integration Test Framework + +## 1. Context + +The `btcwallet` project requires a robust integration testing framework to verify the correctness of its core `wallet` package against various configurations. The current testing landscape is insufficient for verifying the complex matrix of supported backends and modes. + +There is a requirement to support: +1. **Multiple Chain Backends**: `btcd` (native), `bitcoind` (external process), and `neutrino` (SPV). +2. **Multiple Database Backends**: `kvdb` (bbolt/etcd), `sqlite`, and `postgres`. +3. **Public API Testing**: Verifying the public methods of the `wallet` package (e.g., `Create`, `Load`, `Start`, `Stop`). + +We need a standardized, reusable approach to write integration tests that can run across all these permutations without duplicating setup logic. + +## 2. Decision + +We will implement a modular integration test framework modeled after `lnd`'s `lntest`, adapted for library-mode testing. + +### 2.1. Library-Mode Testing +Tests will run `btcwallet` in **Library Mode** (in-process). +- **Why**: Faster execution, easier debugging, and direct internal state assertion. +- **How**: The test harness instantiates `wallet.Manager` directly. + +### 2.2. Architecture & Components + +The framework is split into `bwtest` (framework) and `itest` (test cases). + +#### Component Interaction + +```mermaid +graph TD + subgraph "Test Execution (itest)" + Test["Test Function (t.Run)"] + Harness["HarnessTest (Instance)"] + end + + subgraph "Infrastructure (bwtest)" + Miner["Miner (btcd)"] + ChainNode["Chain Node Process"] + DB["Database Backend"] + end + + subgraph "Application Under Test" + CB["ChainBackend (Interface Wrapper)"] + W["Wallet (In-Memory)"] + end + + Test -->|Creates & Owns| Harness + Harness -->|Starts| Miner + Harness -->|Starts| ChainNode + Harness -->|Initializes| DB + Harness -->|Configures| CB + Harness -->|Creates| W + + ChainNode -->|"P2P Sync"| Miner + CB -->|"RPC / P2P"| ChainNode + W -->|"Uses (chain.Interface)"| CB + W -->|"Stores Data"| DB +``` + +* **`Miner`**: A dedicated `btcd` instance responsible for generating blocks. It acts as the source of truth for the blockchain state. +* **`Chain Node`**: The software providing chain data (`btcd`, `bitcoind`). + * For `btcd` tests: A separate `btcd` process is started as the Chain Node and connects to the Miner. + * For `bitcoind` tests: A separate `bitcoind` process connects (P2P) to the Miner to sync. + * For `neutrino` tests: There is no separate "Chain Node" process; Neutrino connects directly to the Miner. +* **`ChainBackend`**: The interface wrapper (`rpcclient` or `neutrino.ChainService`) used by the Wallet to communicate with the Chain Node. +* **`Database Backend`**: The storage layer (`kvdb`, `postgres`, `sqlite`). +* **`HarnessTest`**: The specific test instance. It manages unique ports, temp directories, and process lifecycles. + +### 2.3. Package Structure + +- **`bwtest/`**: + - `harness.go`: `HarnessTest` orchestrator. Manages `t.Cleanup` for resource teardown. + - `chain_backend.go`: Logic to start/stop `btcd`/`bitcoind` processes and configure `neutrino`. + - `miner.go`: `Miner` implementation (controls the primary `btcd` instance). + - `database.go`: Helpers to setup/teardown DBs (create temp bolt files, init SQL schemas). +- **`itest/`**: + - `main_test.go`: Flag parsing (`-chain`, `-db`) and global test runner. + - `manager_test.go`: Test cases for `wallet.Manager`. + +### 2.4. Configuration & Isolation + +Configuration is handled via `go test` flags. + +```bash +# Default (btcd + kvdb) +make itest + +# Explicit configuration +make itest chain=bitcoind db=postgres + +# Run specific test case +make itest case=TestNewWallet +``` + +* **Sequential Execution**: Tests run sequentially to avoid resource exhaustion and port conflicts, given the heavy overhead of spinning up multiple full nodes per test. +* **Neutrino Support**: The `Miner` (btcd) will be configured with `--cfilters` to serve compact block filters, allowing Neutrino clients to connect directly to it for SPV synchronization. + +## 3. Implementation Plan + +1. **Scaffold Framework (`bwtest`)**: + - Implement `Miner` (wraps `rpctest`). + - Implement `ChainBackend` logic (start bitcoind/btcd process, setup neutrino). + - Implement `Database` setup (init postgres schema, temp sqlite files). + - Implement `HarnessTest` to orchestrate the dependency graph: + `Miner -> ChainNode -> ChainBackend -> DB -> Wallet`. +2. **Scaffold Tests (`itest`)**: + - Create `manager_test.go` as a proof-of-concept. +3. **CI Integration**: + - Update `Makefile` with `itest` targets mapping flags correctly. + +## 4. Consequences + +### Pros +- **Consistency**: Clear separation between Network, Infrastructure, and Application. +- **Isolation**: Per-test harnesses prevent state leaks. +- **Coverage**: capable of validating the entire support matrix. + +### Cons +- **Resource Intensity**: Running a separate `bitcoind`/`btcd` process per test is CPU/RAM intensive. +- **Complexity**: Dynamic port allocation and process lifecycle management are error-prone. +- **Execution Time**: Tests will take longer to run due to sequential execution and process startup costs. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index 90cbbf1959..c2efa58d16 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -7,3 +7,4 @@ ADRs serve as a historical log of important design choices, providing context fo ## Existing ADRs * [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) +* [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) From 1b9d7bf388bf0599f742a4df089a86368aa5f671 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:48 +0800 Subject: [PATCH 093/695] bwtest: add wait.NoError polling helper Introduce a small polling helper for itests so harness setup and sync checks can wait for eventual consistency without fixed sleeps. --- bwtest/wait/wait.go | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 bwtest/wait/wait.go diff --git a/bwtest/wait/wait.go b/bwtest/wait/wait.go new file mode 100644 index 0000000000..fd41a5ebae --- /dev/null +++ b/bwtest/wait/wait.go @@ -0,0 +1,58 @@ +// Package wait provides polling helpers for integration tests. +package wait + +import ( + "errors" + "time" +) + +var ( + // ErrNoResponse is returned when f does not return within the timeout. + ErrNoResponse = errors.New("method did not return within the timeout") +) + +// PollInterval is the default polling interval used by NoError. +const PollInterval = 200 * time.Millisecond + +// NoError polls f until it returns nil or the timeout is reached. +// +// If the timeout is reached, the last error returned by f is returned. +func NoError(f func() error, timeout time.Duration) error { + // f is expected to be cheap and non-blocking. This helper is intended for + // polling state (e.g. "is the node ready?") rather than performing a long + // operation. + // + // NOTE: NoError does not interrupt f. If f blocks, NoError may block longer + // than the provided timeout. + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + ticker := time.NewTicker(PollInterval) + defer ticker.Stop() + + // Call f() immediately to avoid the initial ticker delay. + lastErr := f() + if lastErr == nil { + return nil + } + + for { + select { + case <-deadline.C: + if lastErr == nil { + return ErrNoResponse + } + + return lastErr + + case <-ticker.C: + err := f() + if err == nil { + return nil + } + + lastErr = err + } + } +} From 57fb2d9ea7d9394c6af2583cbd5309e69b39146a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 094/695] bwtest: add rpctest miner wrapper Wrap rpctest.Harness to provide a shared btcd miner for itests, keeping logs under itest/test-logs with time-based directory names. --- bwtest/miner.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ bwtest/utils.go | 39 ++++++++++ 2 files changed, 241 insertions(+) create mode 100644 bwtest/miner.go create mode 100644 bwtest/utils.go diff --git a/bwtest/miner.go b/bwtest/miner.go new file mode 100644 index 0000000000..c542dc5138 --- /dev/null +++ b/bwtest/miner.go @@ -0,0 +1,202 @@ +package bwtest + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +const ( + // MinerLogFilename is the default log filename for the miner node. + MinerLogFilename = "output_btcd_miner.log" + + // MinerLogDir is the default log dir for the miner node. + // + // Note: When running the integration tests with `go test ./itest`, the + // working directory is `itest`, so logs are written under + // `itest/test-logs`. + MinerLogDir = "test-logs" + + // minerSetupOutputs is the number of outputs to generate during miner + // setup. + minerSetupOutputs = 50 + + // minMatureBlocks is the minimum number of blocks to mine to ensure + // coinbase maturity. + minMatureBlocks = 100 + + // retryMultiplier is the multiplier for connection retries to make tests + // more robust. + retryMultiplier = 2 + + // minerWindowMultiplier is the multiplier for the miner confirmation + // window to ensure we mine enough blocks for activation. + minerWindowMultiplier = 2 + + // minerLogDirPerm is the file mode used when creating the miner log dir. + minerLogDirPerm = 0o750 + + // maxMinerLogDirAttempts is the maximum number of attempts to create a + // unique log directory. + maxMinerLogDirAttempts = 1000 +) + +var ( + // harnessNetParams is the network parameters used for the harness. + harnessNetParams = &chaincfg.RegressionNetParams +) + +// minerHarness is a wrapper around rpctest.Harness that provides a mining node +// for integration tests. +type minerHarness struct { + *testing.T + + *rpctest.Harness + + // logPath is the directory path of the miner's logs. + logPath string + + // logFilename is the saved log filename of the miner node. + logFilename string +} + +// newMiner creates a new minerHarness instance. +func newMiner(t *testing.T) *minerHarness { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + logDir := createMinerLogDir(t) + + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + // We use an empty handlers struct as we don't need to handle notifications + // directly in the miner wrapper for now. + handlers := &rpcclient.NotificationHandlers{} + + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create rpctest harness") + + m := &minerHarness{ + T: t, + Harness: harness, + logPath: logDir, + logFilename: MinerLogFilename, + } + + return m +} + +// createMinerLogDir creates a per-run log directory for the miner. +// +// The directory is named using the format log-YYYYMMDD-HHMMSS. If the +// directory already exists, a numeric suffix is appended. +func createMinerLogDir(t *testing.T) string { + t.Helper() + + // Ensure the log root exists. + err := os.MkdirAll(MinerLogDir, minerLogDirPerm) + require.NoError(t, err, "unable to create miner log root") + + base := "log-" + time.Now().Format("20060102-150405") + + for i := range maxMinerLogDirAttempts { + dir := base + if i > 0 { + dir = fmt.Sprintf("%s-%d", base, i) + } + + fullPath := filepath.Join(MinerLogDir, dir) + + err := os.Mkdir(fullPath, minerLogDirPerm) + if err == nil { + return fullPath + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create miner log dir") + } + + t.Fatalf( + "unable to create miner log dir: too many collisions (%d)", + maxMinerLogDirAttempts, + ) + + return "" +} + +// SetUp starts the miner node and generates initial blocks to activate SegWit. +func (m *minerHarness) SetUp() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + require.NoError( + m, m.Harness.SetUp(true, minerSetupOutputs), + "unable to setup miner", + ) + + // Mine enough blocks to activate SegWit. + // MinerConfirmationWindow is usually 144 for mainnet, but likely smaller + // for regtest. For rpctest, standard is often to mine ~200 blocks + // total to ensure maturity and activation. Assuming harness params are + // standard regtest. + numBlocks := max( + harnessNetParams.MinerConfirmationWindow*minerWindowMultiplier, + minMatureBlocks, + ) + + _, err := m.Client.Generate(numBlocks) + require.NoError(m, err, "unable to generate initial blocks") +} + +// SetUpNoChain starts the miner node without generating a test chain. +// +// This is intended for scenarios where the miner will sync to an existing +// chain (for example, when spawning a temporary miner for reorg tests). +func (m *minerHarness) SetUpNoChain() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + // SetUp(true, 0) starts the node, sets up the in-memory wallet, and + // registers notifications, but does not mine any blocks. + require.NoError( + m, m.Harness.SetUp(true, 0), + "unable to setup miner", + ) +} + +// Stop shuts down the miner. +func (m *minerHarness) Stop() { + require.NoError(m, m.TearDown(), "tear down miner failed") + + // Always keep logs for debugging, even for passing tests. + m.Logf("Miner logs available at: %s", m.logPath) +} diff --git a/bwtest/utils.go b/bwtest/utils.go new file mode 100644 index 0000000000..baf7ede64c --- /dev/null +++ b/bwtest/utils.go @@ -0,0 +1,39 @@ +package bwtest + +import ( + "fmt" + "os/exec" + "path/filepath" +) + +// GetBtcdBinary returns the path to the btcd binary. +// It checks if "btcd" is in the PATH. +func GetBtcdBinary() (string, error) { + // If specific path is needed, we could check env vars here. + path, err := exec.LookPath("btcd") + if err != nil { + return "", fmt.Errorf("failed to find btcd binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} + +// GetBitcoindBinary returns the path to the bitcoind binary. +func GetBitcoindBinary() (string, error) { + path, err := exec.LookPath("bitcoind") + if err != nil { + return "", fmt.Errorf("failed to find bitcoind binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} From b6025498050cad7c1134efef2d1aad0c284b5601 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 095/695] bwtest: add backend and database helpers Add a minimal chain backend abstraction plus helpers for opening wallet DBs across supported backends, sharing common timeouts and constants. --- bwtest/chain_backend.go | 133 ++++++++++++++++++++++++++++++++++++++++ bwtest/database.go | 53 ++++++++++++++++ bwtest/timeouts.go | 9 +++ 3 files changed, 195 insertions(+) create mode 100644 bwtest/chain_backend.go create mode 100644 bwtest/database.go create mode 100644 bwtest/timeouts.go diff --git a/bwtest/chain_backend.go b/bwtest/chain_backend.go new file mode 100644 index 0000000000..c6a8eb2d76 --- /dev/null +++ b/bwtest/chain_backend.go @@ -0,0 +1,133 @@ +// Package bwtest provides the integration test framework for btcwallet. +package bwtest + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +// ChainBackend defines the interface that all chain backends must implement. +type ChainBackend interface { + // Start launches the chain backend process. + Start() error + + // Stop shuts down the chain backend process. + Stop() error + + // RPCConfig returns the credentials to connect to this backend. + RPCConfig() rpcclient.ConnConfig + + // P2PAddr returns the P2P address of this node. + P2PAddr() string + + // ConnectMiner connects this node to the miner. + ConnectMiner(minerAddr string) error + + // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). + Name() string +} + +// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). +type BtcdBackend struct { + // harness is the underlying rpctest harness that manages the btcd process. + harness *rpctest.Harness +} + +// NewBtcdBackend creates a new BtcdBackend. +func NewBtcdBackend(t *testing.T) *BtcdBackend { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + // Create a separate harness for the chain backend. + // We use the same regression net params. + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + handlers := &rpcclient.NotificationHandlers{} + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create btcd backend harness") + + return &BtcdBackend{ + harness: harness, + } +} + +// Start launches the btcd node. +func (b *BtcdBackend) Start() error { + // SetUp(false, 0) means we don't treat it as a miner + // (no mining addrs needed immediately) and don't cache block templates. + err := b.harness.SetUp(false, 0) + if err != nil { + return fmt.Errorf("failed to setup btcd harness: %w", err) + } + + return nil +} + +// Stop shuts down the btcd node. +func (b *BtcdBackend) Stop() error { + err := b.harness.TearDown() + if err != nil { + return fmt.Errorf("failed to teardown btcd harness: %w", err) + } + + return nil +} + +// RPCConfig returns the RPC connection config. +func (b *BtcdBackend) RPCConfig() rpcclient.ConnConfig { + return b.harness.RPCConfig() +} + +// P2PAddr returns the P2P address. +func (b *BtcdBackend) P2PAddr() string { + return b.harness.P2PAddress() +} + +// ConnectMiner connects the backend to the miner. +func (b *BtcdBackend) ConnectMiner(minerAddr string) error { + // We use "add" to make it persistent. + err := b.harness.Client.AddNode(minerAddr, "add") + if err != nil { + return fmt.Errorf("failed to add miner node %s: %w", minerAddr, + err) + } + + return nil +} + +// Name returns "btcd". +func (b *BtcdBackend) Name() string { + return "btcd" +} + +// Ensure BtcdBackend implements ChainBackend. +var _ ChainBackend = (*BtcdBackend)(nil) + +// NewBackend creates a ChainBackend based on the type string. +// Currently only supports "btcd". +func NewBackend(t *testing.T, backendType string) ChainBackend { + t.Helper() + + switch backendType { + case "btcd": + return NewBtcdBackend(t) + // TODO: Add bitcoind and neutrino support. + default: + t.Fatalf("unknown backend type: %s", backendType) + return nil + } +} diff --git a/bwtest/database.go b/bwtest/database.go new file mode 100644 index 0000000000..5fc84f17fe --- /dev/null +++ b/bwtest/database.go @@ -0,0 +1,53 @@ +package bwtest + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb driver. +) + +var ( + // ErrUnknownDBBackend is returned when an unknown db backend is requested. + ErrUnknownDBBackend = errors.New("unknown db backend") +) + +const ( + // dbNameKvdb is the identifier used for the kvdb wallet backend. + dbNameKvdb = "kvdb" + + // kvdbDriver is the walletdb driver name used for kvdb. + kvdbDriver = "bdb" + + // walletDBFilename is the default wallet database filename. + walletDBFilename = "wallet.db" +) + +// OpenWalletDB opens a wallet database instance rooted at baseDir. +// +// The returned cleanup function should be called to close the database. +func OpenWalletDB(dbType, baseDir string) (walletdb.DB, func() error, error) { + switch dbType { + case dbNameKvdb: + dbPath := filepath.Join(baseDir, walletDBFilename) + + db, err := walletdb.Create(kvdbDriver, dbPath, true, + defaultTestTimeout, false) + if err != nil { + return nil, nil, fmt.Errorf("unable to create bdb instance: %w", + err) + } + + cleanup := func() error { + return db.Close() + } + + return db, cleanup, nil + + // TODO: Add sqlite and postgres support. + default: + return nil, nil, fmt.Errorf("%w: %s", ErrUnknownDBBackend, dbType) + } +} diff --git a/bwtest/timeouts.go b/bwtest/timeouts.go new file mode 100644 index 0000000000..6f348a8935 --- /dev/null +++ b/bwtest/timeouts.go @@ -0,0 +1,9 @@ +package bwtest + +import "time" + +const ( + // defaultTestTimeout is a shared default timeout for polling and setup + // steps in integration tests. + defaultTestTimeout = 30 * time.Second +) From 1aeff92fd4375f2b4ec27d5c366dced1c2633fb6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 096/695] bwtest: add integration harness core Introduce a HarnessTest that owns shared miner/backend infrastructure and creates per-test chain clients and wallet DBs for isolated test cases. --- bwtest/harness.go | 240 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 bwtest/harness.go diff --git a/bwtest/harness.go b/bwtest/harness.go new file mode 100644 index 0000000000..d9e87507f0 --- /dev/null +++ b/bwtest/harness.go @@ -0,0 +1,240 @@ +package bwtest + +import ( + "runtime/debug" + "sync" + "testing" + + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" +) + +const ( + // defaultChainReconnectAttempts is the number of times the chain RPC client + // will attempt to reconnect before failing. + defaultChainReconnectAttempts = 5 +) + +// HarnessTest is the integration test harness. +type HarnessTest struct { + *testing.T + + // miner is the shared mining node used to generate blocks. + miner *minerHarness + + // Backend is the chain backend under test. + Backend ChainBackend + + // ChainClient is an RPC chain client connected to the active chain backend. + // + // This client is created for each subtest harness and is intended to be + // passed to wallets under test. + ChainClient chain.Interface + + // WalletDB is a wallet database instance created for the current subtest. + WalletDB walletdb.DB + + // dbType is the configured wallet database backend. + dbType string + + // mu protects harness state that can be accessed across the main test and + // subtests. This includes the wallet registry and idempotent shutdown. + mu sync.Mutex + + // wallets is the set of wallets created by a test case. + wallets []*wallet.Wallet + + // stopped prevents stopping shared infrastructure more than once. + stopped bool +} + +// SetupHarness creates a new HarnessTest. +func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { + t.Helper() + + // 1. Start Miner (always btcd). + miner := newMiner(t) + miner.SetUp() + + // 2. Start Chain Backend. + backend := NewBackend(t, chainBackendType) + require.NoError(t, backend.Start(), "failed to start chain backend") + + ht := &HarnessTest{ + T: t, + miner: miner, + Backend: backend, + dbType: dbType, + } + + // Ensure the harness is cleaned up when the test finishes. + t.Cleanup(ht.Stop) + + // 3. Connect Backend to Miner. + // Backend startup can take a moment, so we retry until it succeeds. + err := wait.NoError(func() error { + return backend.ConnectMiner(miner.P2PAddress()) + }, defaultTestTimeout) + require.NoError(t, err, "failed to connect backend to miner") + + return ht +} + +// Subtest creates a child harness that shares the miner and chain backend. +// +// The returned harness has its own wallet registry and per-test resources. +// Callers should not call Stop on the returned harness as it would stop shared +// infrastructure. +func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { + h.Helper() + + st := &HarnessTest{ + T: t, + miner: h.miner, + Backend: h.Backend, + dbType: h.dbType, + } + + // Use the subtest's testing context for miner assertions. + // + // The miner is shared across test cases, but we want failures to be + // attributed to the active subtest. + // + // NOTE: The miner is shared across the whole suite and this assignment + // mutates that shared state. + // + // This is safe because the integration test suite runs subtests serially + // (no t.Parallel()). Do not enable parallel integration cases unless this + // is refactored. + st.miner.T = st.T + + st.setUpChainClient() + st.setUpWalletDB() + + return st +} + +// RegisterWallet registers a wallet with the harness. +// +// Registered wallets are automatically included in harness-level assertions, +// such as MineBlocks. +func (h *HarnessTest) RegisterWallet(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("cannot register nil wallet") + } + + h.mu.Lock() + h.wallets = append(h.wallets, w) + h.mu.Unlock() +} + +// ActiveWallets returns a snapshot of wallets registered with this harness. +func (h *HarnessTest) ActiveWallets() []*wallet.Wallet { + h.Helper() + + h.mu.Lock() + wallets := append([]*wallet.Wallet(nil), h.wallets...) + h.mu.Unlock() + + return wallets +} + +// RunTestCase executes a harness test case. +// +// Any panic from the test function is converted into a fatal test failure with +// a stack trace. +func (h *HarnessTest) RunTestCase(name string, + testFunc func(t *HarnessTest)) { + + h.Helper() + + defer func() { + r := recover() + if r == nil { + return + } + + stack := debug.Stack() + h.Fatalf("failed (%s): panic=%v\n%s", name, r, stack) + }() + + if testFunc == nil { + h.Fatalf("nil test func for %s", name) + } + + // Execute the case. + testFunc(h) +} + +// NetParams returns the chain parameters used by the harness. +func (h *HarnessTest) NetParams() *chaincfg.Params { + h.Helper() + + return harnessNetParams +} + +// Stop shuts down all resources owned by the harness. +func (h *HarnessTest) Stop() { + h.Helper() + + h.mu.Lock() + + if h.stopped { + h.mu.Unlock() + return + } + + h.stopped = true + h.mu.Unlock() + + // Stop the chain backend first to avoid it attempting to reconnect while + // the miner is being torn down. + require.NoError(h, h.Backend.Stop(), "failed to stop chain backend") + + // Finally, stop the miner. + h.miner.Stop() +} + +// setUpChainClient creates and starts an RPC chain client for the active +// harness backend. +func (h *HarnessTest) setUpChainClient() { + h.Helper() + + backendCfg := h.Backend.RPCConfig() + rpcCfg := backendCfg + + chainConfig := &chain.RPCClientConfig{ + Conn: &rpcCfg, + Chain: harnessNetParams, + ReconnectAttempts: defaultChainReconnectAttempts, + } + chainClient, err := chain.NewRPCClientWithConfig(chainConfig) + require.NoError(h, err, "unable to create chain client") + + err = chainClient.Start(h.Context()) + require.NoError(h, err, "unable to start chain client") + h.Cleanup(chainClient.Stop) + + h.ChainClient = chainClient +} + +// setUpWalletDB opens a wallet database for the configured test backend. +func (h *HarnessTest) setUpWalletDB() { + h.Helper() + + dbDir := h.TempDir() + db, cleanup, err := OpenWalletDB(h.dbType, dbDir) + require.NoError(h, err, "unable to create wallet db") + + h.Cleanup(func() { + require.NoError(h, cleanup(), "failed to close database") + }) + + h.WalletDB = db +} From a7887432981ac206eb6373b0d4a45f9185897e07 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 097/695] bwtest: add harness assertions Add harness-level assertions that poll for wallet sync and other global invariants using wait.NoError. --- bwtest/harness_assertions.go | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 bwtest/harness_assertions.go diff --git a/bwtest/harness_assertions.go b/bwtest/harness_assertions.go new file mode 100644 index 0000000000..ac62a554df --- /dev/null +++ b/bwtest/harness_assertions.go @@ -0,0 +1,44 @@ +package bwtest + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/wallet" +) + +var ( + // ErrWalletNotSynced is returned when a wallet has not reached the chain + // tip. + ErrWalletNotSynced = errors.New("wallet not synced") +) + +// AssertWalletSynced polls until the wallet reports it is synced to the +// miner's best known height. +func (h *HarnessTest) AssertWalletSynced(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("nil wallet") + } + + err := wait.NoError(func() error { + syncedTo := w.SyncedTo() + + _, bestHeight, err := h.miner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if syncedTo.Height != bestHeight { + return fmt.Errorf("%w: wallet=%d chain=%d", ErrWalletNotSynced, + syncedTo.Height, bestHeight) + } + + return nil + }, defaultTestTimeout) + if err != nil { + h.Fatalf("wallet sync timeout: %v", err) + } +} From 22ab495c986daab07eef3bbdf1adb59ec225468e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 098/695] bwtest: add mining and mempool assertions Add harness-level mining helpers and mempool/block assertions modeled after lnd's itest harness, including strict no-txn mining helpers and reorg support. --- bwtest/harness_miner.go | 505 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 bwtest/harness_miner.go diff --git a/bwtest/harness_miner.go b/bwtest/harness_miner.go new file mode 100644 index 0000000000..421da225b6 --- /dev/null +++ b/bwtest/harness_miner.go @@ -0,0 +1,505 @@ +package bwtest + +import ( + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/stretchr/testify/require" +) + +var ( + // ErrMempoolTxNotFound is returned when a transaction is not found in the + // miner's mempool. + ErrMempoolTxNotFound = errors.New("transaction not found in mempool") + + // ErrMempoolTxFound is returned when a transaction is found in the miner's + // mempool. + ErrMempoolTxFound = errors.New("transaction found in mempool") + + // ErrMempoolNumTxnsMismatch is returned when the number of transactions in + // the mempool doesn't match the expected value. + ErrMempoolNumTxnsMismatch = errors.New("mempool txn count mismatch") + + // ErrBlockMissingTx is returned when a transaction is not found in a block. + ErrBlockMissingTx = errors.New("transaction not found in block") + + // ErrBlockUnexpectedTxns is returned when a block contains unexpected + // transactions. + ErrBlockUnexpectedTxns = errors.New("block contains unexpected txns") + + // ErrOutpointNotFound is returned when an outpoint is not found in the + // miner's mempool. + ErrOutpointNotFound = errors.New("outpoint not found in mempool") + + // ErrNilBlock is returned when a nil block is provided. + ErrNilBlock = errors.New("nil block") + + // ErrMinerNotSynced is returned when a temporary miner is not synced to the + // harness miner. + ErrMinerNotSynced = errors.New("miner not synced") +) + +const ( + // coinbaseAndOneTxn is the number of transactions expected in a block that + // contains only a coinbase transaction and a single non-coinbase + // transaction. + coinbaseAndOneTxn = 2 +) + +// GenerateBlocks generates the specified number of blocks. +func (h *HarnessTest) GenerateBlocks(num uint32) []*chainhash.Hash { + h.Helper() + + hashes, err := h.miner.Client.Generate(num) + require.NoError(h, err, "unable to generate blocks") + + return hashes +} + +// AssertTxInMempool asserts a transaction can be found in the miner's mempool. +func (h *HarnessTest) AssertTxInMempool(txid chainhash.Hash) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + foundTx = tx.MsgTx() + + return nil + } + } + + return fmt.Errorf("%w: txid=%s", ErrMempoolTxNotFound, txid) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxNotInMempool asserts a transaction cannot be found in the miner's +// mempool. +func (h *HarnessTest) AssertTxNotInMempool(txid chainhash.Hash) { + h.Helper() + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + return fmt.Errorf("%w: txid=%s", ErrMempoolTxFound, + txid) + } + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn to leave mempool") +} + +// AssertNumTxnsInMempool polls until finding the expected number of +// transactions in the miner's mempool. +func (h *HarnessTest) AssertNumTxnsInMempool(n int) []chainhash.Hash { + h.Helper() + + if n < 0 { + h.Fatalf("invalid mempool size: %d", n) + } + + var txids []chainhash.Hash + + err := wait.NoError(func() error { + mempoolTxids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + if len(mempoolTxids) != n { + return fmt.Errorf("%w: want=%d got=%d", ErrMempoolNumTxnsMismatch, + n, len(mempoolTxids)) + } + + txids = mempoolTxids + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for mempool size") + + return txids +} + +// AssertOutpointInMempool asserts an outpoint is spent by a transaction in the +// miner's mempool. +func (h *HarnessTest) AssertOutpointInMempool(op wire.OutPoint) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, txid := range txids { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + msgTx := tx.MsgTx() + for _, txIn := range msgTx.TxIn { + if txIn.PreviousOutPoint == op { + foundTx = msgTx + return nil + } + } + } + + return fmt.Errorf("%w: outpoint=%v", ErrOutpointNotFound, op) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for outpoint in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxInBlock asserts a transaction can be found in a block. +func (h *HarnessTest) AssertTxInBlock(block *wire.MsgBlock, + txid chainhash.Hash) { + + h.Helper() + + if block == nil { + h.Fatalf("nil block") + } + + for _, tx := range block.Transactions { + if tx == nil { + continue + } + + if tx.TxHash() == txid { + return + } + } + + h.Fatalf("%v: block=%v", fmt.Errorf("%w: txid=%s", ErrBlockMissingTx, + txid), block.BlockHash()) +} + +// MineBlocks mines blocks and asserts no transactions are found in the mined +// blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) MineBlocks(num int) { + h.Helper() + + err := h.MineBlocksNoTxns(num) + if err != nil { + require.Fail(h, "MineBlocks", err.Error()) + } +} + +// MineBlocksNoTxns mines blocks and returns an error if any mined block +// contains non-coinbase transactions. +func (h *HarnessTest) MineBlocksNoTxns(num int) error { + h.Helper() + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + return err + } + } + + return nil +} + +// MineEmptyBlocks mines blocks and asserts the mempool remains empty. +// +// This differs from MineBlocks in that it explicitly requires the miner's +// mempool to have no transactions before mining begins. +func (h *HarnessTest) MineEmptyBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Require the mempool is empty before mining, otherwise these blocks might + // confirm pending transactions. + h.AssertNumTxnsInMempool(0) + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + require.Fail(h, "MineEmptyBlocks", err.Error()) + } + } + + return blocks +} + +// MineBlocksAndAssertNumTxns mines blocks and asserts that numTxns +// transactions are included in the first mined block. +func (h *HarnessTest) MineBlocksAndAssertNumTxns(num uint32, + numTxns int) []*wire.MsgBlock { + + h.Helper() + + if num == 0 { + h.Fatalf("invalid block count: %d", num) + } + + txids := h.AssertNumTxnsInMempool(numTxns) + blocks := h.generateBlocks(int(num)) + + for _, txid := range txids { + h.AssertTxInBlock(blocks[0], txid) + h.AssertTxNotInMempool(txid) + } + + return blocks +} + +// MineBlockWithTx mines a single block and asserts it contains the given +// transaction. +func (h *HarnessTest) MineBlockWithTx(tx *wire.MsgTx) *wire.MsgBlock { + h.Helper() + + if tx == nil { + h.Fatalf("nil tx") + } + + txid := tx.TxHash() + h.AssertTxInMempool(txid) + + // Ensure the mempool only contains our transaction so the mined block + // contains only the coinbase and this transaction. + mempoolTxids := h.AssertNumTxnsInMempool(1) + require.Equal(h, txid, mempoolTxids[0], "unexpected txn in mempool") + + blocks := h.MineBlocksAndAssertNumTxns(1, 1) + require.Len(h, blocks, 1, "expected exactly 1 block") + + block := blocks[0] + require.NotNil(h, block, "mined block is nil") + require.Len(h, block.Transactions, coinbaseAndOneTxn, + "expected coinbase and one txn") + require.Equal(h, txid, block.Transactions[1].TxHash(), + "unexpected txn in mined block") + + return block +} + +// SpawnTempMiner creates a temporary miner that is synced with the current +// miner. +// +// This is useful for reorg tests where an alternative chain needs to be mined +// in isolation. +func (h *HarnessTest) SpawnTempMiner() *HarnessTest { + h.Helper() + + tempMiner := newMiner(h.T) + tempMiner.SetUpNoChain() + + th := &HarnessTest{T: h.T, miner: tempMiner} + h.Cleanup(tempMiner.Stop) + + // Connect the miners and wait for the temp miner to sync. + h.ConnectToMiner(th) + + _, mainHeight := h.GetBestBlock() + err := wait.NoError(func() error { + _, tempHeight, err := tempMiner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if tempHeight != mainHeight { + return fmt.Errorf("%w: main=%d temp=%d", ErrMinerNotSynced, + mainHeight, tempHeight) + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for temp miner to sync") + + // Disconnect the temp miner so it can mine an alternative chain. + h.DisconnectFromMiner(th) + + return th +} + +// ConnectToMiner connects the harness miner to tempMiner. +func (h *HarnessTest) ConnectToMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect to temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect temp miner") +} + +// DisconnectFromMiner disconnects the harness miner from tempMiner. +func (h *HarnessTest) DisconnectFromMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect from temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect temp miner") +} + +// generateBlocks mines num blocks and returns the full blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) generateBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Mining 0 blocks is a no-op. + if num == 0 { + return nil + } + + if num < 0 { + h.Fatalf("invalid block count: %d", num) + } + + if num > int(^uint32(0)) { + h.Fatalf("too many blocks requested: %d", num) + } + + blocks := make([]*wire.MsgBlock, 0, num) + for range num { + hashes := h.GenerateBlocks(1) + require.Len(h, hashes, 1, "expected 1 block hash") + + block, err := h.miner.Client.GetBlock(hashes[0]) + require.NoError(h, err, "failed to get mined block") + + blocks = append(blocks, block) + + // Ensure all wallets we created in this test have caught up. + for _, w := range h.ActiveWallets() { + h.AssertWalletSynced(w) + } + } + + return blocks +} + +// getRawMempool returns the miner's mempool transaction ids. +func (h *HarnessTest) getRawMempool() ([]chainhash.Hash, error) { + h.Helper() + + txids, err := h.miner.Client.GetRawMempool() + if err != nil { + return nil, fmt.Errorf("get raw mempool: %w", err) + } + + result := make([]chainhash.Hash, 0, len(txids)) + for _, txid := range txids { + if txid == nil { + continue + } + + result = append(result, *txid) + } + + return result, nil +} + +// blockHasNoTxns returns an error if block contains non-coinbase transactions. +func (h *HarnessTest) blockHasNoTxns(block *wire.MsgBlock) error { + h.Helper() + + if block == nil { + return ErrNilBlock + } + + if len(block.Transactions) <= 1 { + return nil + } + + var desc strings.Builder + desc.WriteString(fmt.Sprintf( + "block %v has %d txns:\n", + block.BlockHash(), len(block.Transactions)-1, + )) + + for _, tx := range block.Transactions[1:] { + if tx == nil { + continue + } + + desc.WriteString(fmt.Sprintf("%v\n", tx.TxHash())) + } + + desc.WriteString( + "Consider using `MineBlocksAndAssertNumTxns` if you expect " + + "txns, or `MineEmptyBlocks` if you want to keep txns " + + "unconfirmed.", + ) + + return fmt.Errorf("%w: %s", ErrBlockUnexpectedTxns, desc.String()) +} + +// SendOutput sends funds from the miner. +func (h *HarnessTest) SendOutput(output *wire.TxOut, + feeRate btcutil.Amount) *chainhash.Hash { + + h.Helper() + + txid, err := h.miner.SendOutputs([]*wire.TxOut{output}, feeRate) + require.NoError(h, err, "failed to send output") + + return txid +} + +// GetBestBlock returns the hash and height of the best block. +func (h *HarnessTest) GetBestBlock() (*chainhash.Hash, int32) { + h.Helper() + + hash, height, err := h.miner.Client.GetBestBlock() + require.NoError(h, err, "failed to get best block") + + return hash, height +} From 382e622e2b06a027b554b8906cd046e5140682d7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 099/695] itest: add case runner Add an lnd-style suite runner that executes registered itest cases under a shared bwtest harness, with case naming validation for component grouping. --- itest/list_on_test.go | 22 ++++++++++++ itest/main_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 itest/list_on_test.go create mode 100644 itest/main_test.go diff --git a/itest/list_on_test.go b/itest/list_on_test.go new file mode 100644 index 0000000000..9b5e0e2cf6 --- /dev/null +++ b/itest/list_on_test.go @@ -0,0 +1,22 @@ +//go:build itest + +package itest + +import "github.com/btcsuite/btcwallet/bwtest" + +// testCase defines a single integration test case. +type testCase struct { + // Name is the human-readable name of the test case. + Name string + + // TestFunc executes the test case. + TestFunc func(t *bwtest.HarnessTest) +} + +// allTestCases is the full set of integration test cases. +var allTestCases = []*testCase{ + { + Name: "manager create wallet", + TestFunc: testCreateWallet, + }, +} diff --git a/itest/main_test.go b/itest/main_test.go new file mode 100644 index 0000000000..1f01854c38 --- /dev/null +++ b/itest/main_test.go @@ -0,0 +1,84 @@ +//go:build itest + +package itest + +import ( + "flag" + "fmt" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcwallet/bwtest" +) + +var ( + // chainBackend defines the blockchain backend to be used for the + // integration tests. + // Options: "btcd" (default), "bitcoind", "neutrino". + chainBackend = flag.String( + "chain", "btcd", + "chain backend to use (btcd, bitcoind, neutrino)", + ) + + // dbBackend defines the database backend to be used for the wallet + // storage. + // Options: "kvdb" (default), "sqlite", "postgres". + // + // This flag allows verifying that the wallet functions correctly across all + // supported database drivers. + dbBackend = flag.String( + "db", "kvdb", + "database backend to use (kvdb, sqlite, postgres)", + ) +) + +// TestBtcWallet runs the btcwallet integration test suite. +func TestBtcWallet(t *testing.T) { + if len(allTestCases) == 0 { + t.Skip("no integration test cases registered") + } + + harness := bwtest.SetupHarness(t, *chainBackend, *dbBackend) + + for _, tc := range allTestCases { + if tc == nil { + continue + } + + validateTestCaseName(t, tc.Name) + + name := fmt.Sprintf("%s/%s", *chainBackend, tc.Name) + + success := t.Run(name, func(st *testing.T) { + ht := harness.Subtest(st) + ht.RunTestCase(tc.Name, tc.TestFunc) + }) + if !success { + t.Logf("failure time: %v", time.Now().Format( + "2006-01-02 15:04:05.000", + )) + break + } + } +} + +// validateTestCaseName enforces a consistent naming convention for integration +// test cases. +// +// Names must be in the format "component action" (space separated), and must +// not include underscores. +func validateTestCaseName(t *testing.T, name string) { + t.Helper() + + if strings.Contains(name, "_") { + t.Fatalf("invalid test case name %q: underscores are not allowed", + name) + } + + words := strings.Fields(name) + if len(words) < 2 { + t.Fatalf("invalid test case name %q: want 'component action'", + name) + } +} From 3155e559e8c0b11a2be68ad6ec0edefa3a01de39 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 100/695] itest: add manager create wallet test case Add a basic manager create wallet case that creates, starts, and syncs a wallet against the harness chain backend and per-case wallet database. --- itest/manager_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 itest/manager_test.go diff --git a/itest/manager_test.go b/itest/manager_test.go new file mode 100644 index 0000000000..c7274818e4 --- /dev/null +++ b/itest/manager_test.go @@ -0,0 +1,53 @@ +//go:build itest + +package itest + +import ( + "context" + "time" + + "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +// testCreateWallet verifies a wallet can be created, started, and synced. +func testCreateWallet(h *bwtest.HarnessTest) { + h.Helper() + + // Create a wallet using the Manager API. + cfg := wallet.Config{ + DB: h.WalletDB, + Chain: h.ChainClient, + ChainParams: h.NetParams(), + RecoveryWindow: 20, + WalletSyncRetryInterval: 500 * time.Millisecond, + Name: "testwallet", + PubPassphrase: []byte("public"), + } + + manager := wallet.NewManager() + params := wallet.CreateWalletParams{ + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now().Add(-1 * time.Hour), + } + + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + h.Cleanup(func() { + // We use a background context here because h.Context() might be + // cancelled already. + require.NoError(h, w.Stop(context.Background()), "failed to stop wallet") + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + // Mine a few blocks and require the wallet catches up. + h.MineBlocks(5) +} From 65de7aa098a4b5fefcdb28509f5867471b62e6ac Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 101/695] build: add itest make target Add a make target for running integration tests and pass through harness flags via -args, including case filtering via icase. --- Makefile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Makefile b/Makefile index f1eff8611c..e34fd8f86b 100644 --- a/Makefile +++ b/Makefile @@ -136,6 +136,22 @@ itest-db-race: @$(call print, "Running $(IT_DB_LABEL) integration tests (race).") env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) +#? itest: Run integration tests +#? itest (vars): chain=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres +#? itest (vars): icase= (filter itest cases) +#? itest (vars): timeout= verbose=1 nocache=1 +#? itest (ex): make itest icase=manager +#? itest (ex): make itest chain=btcd db=kvdb +itest: + @$(call print, "Running integration tests.") + @$(GOTEST) -v ./itest \ + -tags="itest $(DEV_TAGS) nolog" \ + $(if $(icase),-test.run="TestBtcWallet/.*/$(icase)",) \ + $(filter-out -test.run=%,$(TEST_FLAGS)) \ + -args \ + -chain="$(if $(chain),$(chain),btcd)" \ + -db="$(if $(db),$(db),kvdb)" + # ========= # UTILITIES # ========= @@ -279,6 +295,7 @@ sql-lint-check: unit-race \ unit-debug \ unit-bench \ + itest \ itest-db \ itest-db-race \ fmt \ From 9bdf6edd68ec08e5638944f252dcdc384f23ba32 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 102/695] .gitignore: ignore itest logs Ignore integration test log directories produced by the bwtest miner wrapper to keep worktrees clean. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 38ab05487f..218d27859d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ coverage.out *.prof *.test *cpu.out + +# Integration test logs. +itest/test-logs/ + +# Backwards compatibility for older log dir. +itest/.minerlogs/ From 15d51b675fea5816b15c26851f9406eb6b630eee Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 28 Jan 2026 18:15:19 +0800 Subject: [PATCH 103/695] docs: add ADR for tx schema --- docs/developer/adr/0006-wtxmgr-sql-schema.md | 400 +++++++++++++++++++ docs/developer/adr/README.md | 8 +- docs/developer/utxo_data_model.md | 298 ++++++++++++++ 3 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 docs/developer/adr/0006-wtxmgr-sql-schema.md create mode 100644 docs/developer/utxo_data_model.md diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md new file mode 100644 index 0000000000..55778aca79 --- /dev/null +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -0,0 +1,400 @@ +# ADR 0006: Wallet Transaction Manager SQL Schema + +## 1. Context + +As part of the migration from a Key-Value store to a relational SQL backend, the Wallet Transaction Manager (`wtxmgr`) requires a new schema design. The `wtxmgr` is responsible for tracking: +1. **Transactions:** Immutable blockchain data and user intent. +2. **UTXOs (Credits):** Outputs owned by the wallet (the primary operational unit). +3. **Spends (Debits):** Inputs that spend those outputs. +4. **Metadata:** User labels and transient locks (leases). + +For a detailed theoretical analysis of the data model, see [UTXO Data Model and Lifecycle](../utxo_data_model.md). + +Note on txid uniqueness: Bitcoin has historical txid-duplicate edge cases (primarily coinbase). However, since BIP30, duplicates cannot create concurrently-unspent outpoints. This schema therefore treats `tx_hash` as unique per wallet for balance/coin selection purposes. + +## 2. Decision + +We will adopt a **UTXO-Centered, Soft-Deletion Schema**. + +### 2.1 Core Principles +1. **UTXO-Centered Operations:** The schema is optimized for `Balance()` and `CoinSelection()` queries, which focus on the `utxos` table. +2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`status='published'`) or is explicitly allowed for chaining (`status='pending'`). +3. **Immutable History (Soft Deletion):** We **NEVER** automatically `DELETE` rows. + * Failed/RBF'd transactions are marked with a `status` field (e.g., `replaced`, `failed`). + * They remain in the database for audit history but are excluded from balance queries. + * Foreign Keys use `ON DELETE RESTRICT` for creation relationships to prevent accidental data loss. +4. **Wallet-Scoped Rows:** All `wtxmgr` tables are scoped by `wallet_id` to support multiple wallets sharing a single database without row-level conflicts. + +### 2.1.1 Wallet-Scoped vs Global Transactions +Two designs were considered: + +* **Wallet-scoped tables (chosen):** + * Pros: Simple schema; no join tables; queries stay local to a wallet; avoids cross-wallet coordination. + * Cons: Duplicates transaction rows across wallets that observe the same global tx. +* **Global transactions table (alternative):** + * Pros: Storage efficiency; one canonical row per `tx_hash`. + * Cons: Requires additional mapping tables (e.g., `wallet_transactions`) for per-wallet ownership/metadata; more complex queries and constraints; more careful concurrency semantics. + +This ADR chooses wallet-scoped tables for simplicity and implementability. A future migration to a global transactions table is possible but is considered a separate architectural decision. + +### 2.1.2 Explicit Status vs. Derived Status +Two designs were considered for tracking transaction validity: + +* **Explicit `status` column (chosen):** + * The `status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). + * Pros: Balance and coin-selection queries filter on a single status predicate (`status IN ('published', 'pending')`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. + * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. + +* **Derived status from other columns (alternative):** + * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. + * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. + * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. + * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `status` column. + * Cascading invalidation adds further complexity: downstream txs whose parent became invalid were never directly "replaced." Without a pre-computed status, every balance/coin-selection query would need a recursive CTE walking up the ancestor chain — O(depth) per tx on the hot path. + +This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. + +### 2.2 Consistency & Concurrency Model +This design assumes standard SQL ACID guarantees. + +* **Atomic updates:** Reorg disconnects, status transitions (RBF/failure/orphaning), and lease acquisition MUST be performed using explicit SQL transactions. +* **Leases are the concurrency primitive:** `utxo_leases` provides an application-level lock that prevents concurrent coin selection from choosing the same UTXO. +* **Isolation:** Wallet implementations SHOULD treat coin selection + lease acquisition as a single atomic unit. If multiple processes share one database, they must rely on database-enforced constraints (primary keys/uniques) and transactional semantics rather than best-effort in-memory coordination. + +Recommended operational defaults: +* Prefer running write paths under `SERIALIZABLE` and retry on serialization failures. +* If using weaker isolation (e.g., `READ COMMITTED`), rely on unique constraints and single-statement lease acquisition (no read-then-write without conflict handling). + +### 2.3 Reorg & RBF Strategy +* **Reorgs:** The `blocks` table represents the current best chain. If a block is disconnected, any referencing transaction will have `block_height` set to `NULL` via `ON DELETE SET NULL`. + * **Effect (non-coinbase):** Regular transactions become unconfirmed (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (they cannot exist outside the block that created them). + * **Atomicity requirement:** The coinbase status update MUST occur atomically with the disconnect. + * PostgreSQL evaluates `CHECK` constraints immediately, including updates performed by foreign-key actions such as `ON DELETE SET NULL`. + * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `status` is updated to `orphaned` as part of the same statement. + * Recommended: use a trigger to rewrite coinbase `status` during the `block_height -> NULL` update (see 3.6). + * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `status='published'` atomically. +* **RBF:** Handled by updating the `utxos.spent_by_tx_id` pointer to the new transaction and marking the old transaction as `replaced`. + +## 3. Reference Schema + +### 3.1 Wallet: `transactions` +Stores the provenance of funds for a specific wallet. Acts as the source of truth for Validity (`status`) and Confirmation (`block_height`). + +```sql +CREATE TABLE transactions ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), + -- Raw transaction bytes. This is typically TOASTed in PostgreSQL. + -- Hot-path queries (balance/coin selection) SHOULD avoid selecting this column. + raw_tx BYTEA NOT NULL, + + -- Confirmation State: + -- NULL = Unconfirmed (Mempool) + -- INT = Confirmed (Mined) + -- ON DELETE SET NULL: If block is reorged, tx becomes unconfirmed. + block_height INTEGER REFERENCES blocks(block_height) ON DELETE SET NULL, + + -- Validity State (Soft Deletion): + -- pending: Created locally, not yet broadcast. + -- published: Active in mempool or blockchain (Valid); not necessarily broadcast by this wallet. + -- replaced: RBF'd by another transaction (Invalid). + -- failed: Double-spent by a competitor (Invalid). + -- orphaned: Coinbase tx that was reorged out (Invalid). + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Absolute wall clock time, stored in UTC. + received_time TIMESTAMPTZ NOT NULL, + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Composite primary key is intentional: it allows foreign keys to enforce + -- wallet scoping (preventing cross-wallet references). Do not replace this + -- with a single-column PK on `id` unless all referencing FKs are also + -- reworked to maintain the same invariant. + CONSTRAINT pidx_transactions PRIMARY KEY (wallet_id, id), + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + CONSTRAINT valid_status CHECK (status IN ('pending', 'published', 'replaced', 'failed', 'orphaned')), + -- Invariant: If a transaction is confirmed (mined), it must be 'published'. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR status = 'published' + ), + -- Invariant: Coinbase transactions cannot exist in the mempool. + -- If a coinbase transaction loses its block via a reorg, it becomes orphaned. + CONSTRAINT check_coinbase_not_pending CHECK (NOT (is_coinbase AND status = 'pending')), + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase OR + (block_height IS NOT NULL AND status = 'published') OR + (block_height IS NULL AND status = 'orphaned') + ) +); + +-- Optimization for Mempool lookups +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Optimization for status-based filtering (non-hot path) +-- Optional: profile before enabling (low cardinality) +-- CREATE INDEX idx_transactions_by_status +-- ON transactions (wallet_id, status); +``` + +### 3.2 Local: `utxos` +The **Single Source of Truth** for wallet balance. +Note: The table keeps the `utxos` name for consistency with wallet abstractions, even though it retains spent rows for audit history (`spent_by_tx_id` marks spent outputs). + +```sql +CREATE TABLE utxos ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + + -- Creation (OutPoint): + -- ON DELETE RESTRICT: We NEVER delete confirmed transactions or their UTXOs. + -- To remove history, the user must explicitly Prune (manual op). + tx_id BIGINT NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + amount BIGINT NOT NULL CHECK (amount >= 0), + -- The output script is stored on the address record (see the address-manager + -- schema). `addresses.script_pub_key` is expected to be NOT NULL for all + -- addresses, including HD-derived addresses. + -- Optional address book reference for indexing and UX. + -- The `addresses` table is part of the address-manager schema (tracked + -- separately from this ADR's `wtxmgr` schema). + -- + -- Note: in the draft address schema (`wallet/internal/db/migrations/*/000006_addresses.up.sql`), + -- `addresses` is keyed by an `account_id` that is ultimately rooted in a + -- specific wallet (via key scopes). Implementations must ensure `address_id` + -- always refers to an address belonging to the same `wallet_id`. + address_id BIGINT NOT NULL REFERENCES addresses(id) ON DELETE RESTRICT, + + -- Spending (Input): + -- ON DELETE SET NULL: If the spending tx is manually pruned, the UTXO becomes unspent. + spent_by_tx_id BIGINT, + -- NULL when unspent; non-NULL when spent (enforced by pair constraint). + spent_input_index INTEGER CHECK (spent_input_index IS NULL OR spent_input_index >= 0), + + -- Composite primary key is intentional: it allows leases and other + -- references to enforce wallet scoping. + CONSTRAINT pidx_utxos PRIMARY KEY (wallet_id, id), + CONSTRAINT fkey_utxos_tx FOREIGN KEY (wallet_id, tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE RESTRICT, + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (wallet_id, spent_by_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE SET NULL, + + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) OR + (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + CONSTRAINT uidx_utxos_outpoint UNIQUE (wallet_id, tx_id, output_index) +); + +-- Optimization for Balance Queries (Index-Only Scan) +CREATE INDEX idx_utxos_unspent ON utxos (address_id, amount) WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent) +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction +CREATE INDEX idx_utxos_spent_by ON utxos(wallet_id, spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction +CREATE INDEX idx_utxos_by_tx ON utxos(wallet_id, tx_id); +``` + +Denormalization note: +* This schema is normalized: the canonical locking script is stored in the address book (`addresses.script_pub_key`). +* Joining through `address_id` adds work to the signing/reconstruction path, but keeps the hot UTXO table small and avoids duplicating script data. + +Cross-wallet integrity note: +* Ideally, the database should prevent a `utxos` row from referencing an `address_id` belonging to a different wallet. +* The strongest enforcement is a composite FK `FOREIGN KEY (wallet_id, address_id) REFERENCES addresses(wallet_id, id)`. +* If the address-manager schema does not expose `wallet_id` on `addresses`, this invariant must be enforced by application logic. + +### 3.3 Audit: `tx_replacements` +Tracks the history of RBF and Double-Spends. + +```sql +CREATE TABLE tx_replacements ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + + -- ON DELETE CASCADE: Supports Manual Pruning. + -- If a transaction is physically deleted (to save space), + -- its audit history is automatically cleaned up to prevent FK violations. + replaced_tx_id BIGINT NOT NULL, + replacement_tx_id BIGINT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Composite primary key is intentional: it enforces wallet scoping. + CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY (wallet_id, replaced_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY (wallet_id, replacement_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, + CONSTRAINT check_not_self_replacement CHECK (replaced_tx_id != replacement_tx_id), + CONSTRAINT uidx_tx_replacements_edge UNIQUE (wallet_id, replaced_tx_id, replacement_tx_id) +); +``` + +### 3.4 Local: `utxo_leases` +Transient application locks. + +```sql +CREATE TABLE utxo_leases ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + utxo_id BIGINT NOT NULL, + external_lock_id BYTEA NOT NULL CHECK (length(external_lock_id) = 32), + expires_at TIMESTAMPTZ NOT NULL, + + -- Composite primary key is intentional: it enforces wallet scoping. + CONSTRAINT pidx_utxo_leases PRIMARY KEY (wallet_id, utxo_id), + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (wallet_id, utxo_id) + REFERENCES utxos(wallet_id, id) ON DELETE CASCADE +); + +-- Optimization for lease cleanup +CREATE INDEX idx_utxo_leases_expires_at ON utxo_leases(expires_at); + +-- Lease cleanup is expected to be periodic: +-- DELETE FROM utxo_leases WHERE expires_at <= CURRENT_TIMESTAMP; + +-- Lease acquisition is expected to be a single atomic statement. +-- Recommended pattern (PostgreSQL): acquire if absent, renew if same external_lock_id, +-- or steal only if expired. +-- Portability note: SQLite supports UPSERT, but uses different time functions. +-- Prefer `CURRENT_TIMESTAMP` in cross-database examples. +-- The `WHERE` clause in `ON CONFLICT DO UPDATE` is supported in SQLite 3.24.0+; verify compatibility with your target version. +-- +-- INSERT INTO utxo_leases (wallet_id, utxo_id, external_lock_id, expires_at) +-- VALUES ($1, $2, $3, CURRENT_TIMESTAMP + $4) +-- ON CONFLICT (wallet_id, utxo_id) DO UPDATE +-- SET external_lock_id = EXCLUDED.external_lock_id, +-- expires_at = EXCLUDED.expires_at +-- WHERE utxo_leases.expires_at <= CURRENT_TIMESTAMP +-- OR utxo_leases.external_lock_id = EXCLUDED.external_lock_id +-- RETURNING expires_at; +-- +-- If no row is returned, the UTXO is currently leased by another external_lock_id. + +Deadlock avoidance note: +* When acquiring multiple leases in one transaction, acquire them in a stable order + (for example sorted by `(wallet_id, utxo_id)`) to reduce deadlock risk. +``` + +### 3.5 Convenience Views + +**`spendable_utxos` View:** +Encapsulates the logic of joining transactions to filter out invalid/failed parents. +Note: Filtering for maturity (Coinbase > 100 confs) is done at the application layer using the exposed `block_height` and `is_coinbase` columns, as Views cannot accept dynamic parameters like `current_height`. + +Suggested helper (parameterized query): +* Expose a query helper that takes `current_height` and filters out immature coinbase outputs: + * `WHERE (NOT is_coinbase) OR (current_height - block_height) >= 100` + +Important: The view includes `pending` parent transactions to enable zero-latency chaining. This is an advanced mode and should be opt-in for conservative spending policies. + +Note: Leases are time-based and depend on the database's current time function. For clarity, the view does not attempt to exclude leased UTXOs. Coin selection MUST exclude active leases (for example using a `NOT EXISTS` subquery against `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`). + +```sql +CREATE VIEW spendable_utxos AS +SELECT + u.*, + t.block_height, + t.is_coinbase, + t.status as tx_status +FROM utxos u +JOIN transactions t ON t.wallet_id = u.wallet_id AND t.id = u.tx_id +WHERE u.spent_by_tx_id IS NULL + AND t.status IN ('published', 'pending'); +``` + +### 3.6 Triggers (PostgreSQL) + +To enforce the coinbase invariant under `ON DELETE SET NULL`, PostgreSQL needs a trigger because `CHECK` constraints are not deferrable. + +Recommended behavior: +* When `block_height` transitions from `NOT NULL` to `NULL`, and `is_coinbase = TRUE`, automatically rewrite `status = 'orphaned'`. + +This can be implemented with a `BEFORE UPDATE` trigger on `transactions`. Foreign-key actions execute as ordinary `UPDATE` statements, and triggers on the referencing table will fire. + +Portability note: +* PostgreSQL: Foreign-key actions (such as `ON DELETE SET NULL`) are performed via ordinary `UPDATE` statements on the referencing table, and triggers on that table will fire. +* SQLite: Foreign-key actions occur after the parent row operation. If you cannot rely on triggers to rewrite `status` during the FK action, enforce coinbase orphaning with an explicit application-side update in the same SQL transaction as the disconnect. + +**SQLite example:** To atomically disconnect a block and orphan its coinbase transactions, run the following within a single transaction: +```sql +BEGIN; +DELETE FROM blocks WHERE block_height = ?; +UPDATE transactions SET status = 'orphaned' WHERE is_coinbase AND block_height IS NULL; +COMMIT; +``` + +Example sketch: + +```sql +CREATE FUNCTION set_coinbase_orphaned_on_disconnect() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL AND NEW.is_coinbase THEN + NEW.status := 'orphaned'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect +BEFORE UPDATE OF block_height ON transactions +FOR EACH ROW +EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); +``` + +## 4. Consequences + +### 4.1. True Multi-Wallet Support +All `wtxmgr` tables are scoped by `wallet_id`. This allows multiple wallets to share the same database without conflicting unique constraints (for example, outpoints and transaction hashes are unique per wallet). + +Note: A separate, truly global `transactions` table shared across wallets is a different design. That approach would require a join table (e.g., `wallet_transactions`) to track per-wallet ownership and metadata. + +### 4.2. Native SQL Efficiency +Balances are calculated using `SUM(amount)` on the `utxos` table (or `spendable_utxos` view), leveraging database optimizations. + +### 4.3. Audit Trail +By using "Soft Deletion" (`status='replaced'`), we maintain a complete history of user attempts, even those that failed. This is superior to previous designs that physically deleted failed transactions. + +### 4.4. Complexity Trade-off +We accept slightly more complexity in **Transaction Reconstruction** (joining inputs/outputs) in exchange for maximal performance in **Balance Calculation** and **Coin Selection**, which are the high-frequency operations. + +Additional operational consequences: +* **Pending-chaining is advanced:** The `spendable_utxos` view includes `pending` parents to enable zero-latency chaining. This increases operational risk (child transactions depend on parents being broadcast) and should be disabled for conservative policies. +* **Recursive invalidation is unbounded in theory:** Marking downstream transactions `failed` after an upstream double-spend can require recursive graph traversal. Implementations should assume bounded typical depth but plan for worst-case behavior (e.g., set a maximum recursion depth or iteration limit). + +### 4.5 Operational Notes (Out of Scope for This ADR) +This ADR defines the target schema and invariants. Production operations still require explicit policies and tooling: + +* **Pruning:** Define criteria for manual pruning (what can be deleted, and how to preserve audit semantics). +* **Migration:** Define how to migrate from the existing key-value store (offline conversion, incremental migration, rollback plan). +* **Backup/Restore:** Large immutable histories impact backup size and restore time; restores may require revalidation of unconfirmed transactions. + * After restore, the wallet should re-fetch the current mempool and re-evaluate RBF/double-spend status for unconfirmed transactions. +* **Schema evolution:** Future changes should be delivered via versioned migrations; preserve immutable-history guarantees when introducing new columns or constraints. + +Monitoring note: +* Track table growth, count of active leases, and lease cleanup latency. These are important for long-running nodes and multi-process deployments. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index c2efa58d16..bdd3aa154b 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -6,5 +6,9 @@ ADRs serve as a historical log of important design choices, providing context fo ## Existing ADRs -* [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) -* [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) +- [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) - Decides on the architecture for managing multiple distinct wallets and networks within a single daemon instance. +- [ADR 0002: Controller-Syncer-State Architecture](./0002-controller-syncer-architecture.md) - Decouples lifecycle management, synchronization logic, and state tracking from the monolithic `Wallet` struct. +- [ADR 0003: Optimistic CFilter Batch Scanning](./0003-optimistic-cfilter-batching.md) - Optimizes BIP 157/158 Compact Filter synchronization using optimistic batch scanning. +- [ADR 0004: Targeted Rescan vs. Global Rewind](./0004-targeted-rescan-vs-rewind.md) - Introduces "Targeted Rescans" to replace global "Rewinds" for more efficient transaction discovery. +- [ADR 0005: Explicit Rescan on Import](./0005-no-auto-rescan-on-import.md) - Disables automatic blockchain scanning during import operations, requiring explicit user initiation. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md new file mode 100644 index 0000000000..ed077c2390 --- /dev/null +++ b/docs/developer/utxo_data_model.md @@ -0,0 +1,298 @@ +# Wallet Data Model and Lifecycle + +This document defines the core data model, state machines, and design philosophy of the `btcwallet` transaction subsystem. It serves as the definitive reference for understanding how the wallet manages money, history, and state. + +## 1. Design Philosophy + +The `btcwallet` architecture is built upon a specific worldview: **The wallet is a UTXO Manager.** + +While Bitcoin users often think in terms of "Balances" and "Transactions," the operational reality of the Bitcoin protocol is the **Unspent Transaction Output (UTXO)**. A wallet's primary responsibility is to discover, secure, and select these outputs for spending. + +### 1.1 Structural vs. Operational Centrality + +Our data model distinguishes between data integrity and operational efficiency: + +* **Structurally Transaction-Centered:** Data integrity flows from the parent Transaction to the child UTXO. A UTXO cannot exist without a creating transaction. If a transaction is invalid (double-spent), its outputs are invalid. +* **Operationally UTXO-Centered:** The read path is optimized for UTXOs. 99% of wallet operations (calculating balance, selecting coins for a new payment) query the **UTXO set**, not the transaction history. + +### 1.2 The "Immutable History" Policy + +We adhere to a strict **"Never Delete History"** policy. +* **Chain Data is Immutable:** Once a transaction is mined, it happened. We record it. +* **Intent is Immutable:** If a user attempts a transaction that later fails (e.g., RBF replaced), that attempt is part of the wallet's audit trail. +* **Soft Deletion:** We do not physically `DELETE` rows. Invalid or replaced transactions are marked with a status (`failed`, `replaced`) so they are ignored by balance calculations but preserved for history. + +--- + +## 2. The Data Model + +The wallet state is composed of four primary entities. + +```mermaid +classDiagram + direction LR + + class Block { + Height + Hash + Timestamp + } + + class Transaction { + TxHash + RawBytes + Status + BlockHeight (FK) + } + + class UTXO { + OutPoint (TxHash:Index) + Amount + Address + SpentBy (FK) + } + + class Lease { + LockID + Expiration + } + + Block "1" --> "0..*" Transaction : Confirms + Transaction "1" --> "0..*" UTXO : Creates + Transaction "0..1" --> "0..*" UTXO : Spends (Inputs) + UTXO "1" --> "0..1" Lease : Locks +``` + +Note: This diagram is conceptual and uses natural identifiers like `TxHash` and an outpoint (`TxHash:Index`). The SQL schema uses surrogate keys (for example `transactions.id`) and scopes all `wtxmgr` rows by `wallet_id`. + +Mapping (conceptual -> SQL): + +| Concept | SQL (example) | +| --- | --- | +| `TxHash` | `transactions.tx_hash` (unique per `wallet_id`) | +| Transaction row ID | `(transactions.wallet_id, transactions.id)` | +| OutPoint (`TxHash:Index`) | `(utxos.wallet_id, utxos.tx_id, utxos.output_index)` | +| Address (address book) | `addresses.id` (address-manager schema) | +| Confirmation | `transactions.block_height` (FK to `blocks(block_height)`) | +| Lease | `(utxo_leases.wallet_id, utxo_leases.utxo_id)` | + +### 2.1 Entity Roles + +* **Block:** The anchor for time and confirmation. It handles reorgs. If a block is disconnected, linked transactions automatically lose their confirmation status. +* **Transaction:** The provenance record. It stores the *intent* (Who sent? When?) and the *status* (Confirmed? Failed?). +* **UTXO:** The unit of value. This is the "Coin". It is the bridge between transactions. +* **Lease:** A temporary, memory-like lock on a UTXO to prevent double-spending within the wallet application (e.g., reserving a coin for a Lightning channel open). + +--- + +## 3. Transaction Lifecycle + +A Transaction represents an event that *moves* value. Its lifecycle tracks the journey from user intent to blockchain finality. + +### 3.1 State Machine + +```mermaid +stateDiagram-v2 + [*] --> Pending : User Creates + [*] --> Published : P2P/Mempool + + Pending --> Published : Broadcast + + %% Confirmation is orthogonal (BlockHeight != NULL) + + Published --> Replaced : RBF (Higher Fee Tx Wins) + Published --> Failed : Double-Spend (Other Tx Wins) + Published --> Orphaned : Reorged Coinbase + + Pending --> Failed : Inputs Spent Elsewhere + + %% Coinbase can re-enter the best chain after a reorg. + Orphaned --> Published : Reconfirmed + + Replaced --> [*] + Failed --> [*] + Orphaned --> [*] +``` + +### 3.2 Status vs. Confirmation + +We distinguish between a transaction's **Validity** (Status) and its **Confirmation** (Block Height). + +**1. Confirmation (Source of Truth: `BlockHeight`)** +* **Unconfirmed:** `BlockHeight IS NULL` +* **Confirmed:** `BlockHeight IS NOT NULL` (points to a valid block) + +**2. Validity (Source of Truth: `Status`)** + +| Status | Meaning | Affects Balance? | +| :--- | :--- | :--- | +| `pending` | Created locally, not yet broadcast. | **No** (Locked) | +| `published` | Active in mempool or blockchain. | **Yes** (If valid) | +| `replaced` | Replaced by a higher-fee transaction (RBF). | **No** | +| `failed` | Invalidated by a conflicting transaction (Double-Spend). | **No** | +| `orphaned` | Coinbase tx that was reorged out (Invalid). | **No** | + +*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status='published'`) or orphaned (`Status='orphaned'` and `BlockHeight IS NULL`).* + +*Note: There is no "Abandoned" state. A broadcast transaction cannot be safely abandoned; it can only be invalidated by double-spending its inputs.* + +--- + +## 4. UTXO Lifecycle + +A UTXO represents **spendable potential**. Its state is derived entirely from its parent Transaction and its "Spent By" pointer. + +### 4.1 State Machine + +```mermaid +stateDiagram-v2 + state "Spendable (Unspent)" as Unspent + state "Immature (Coinbase)" as Immature + state "Leased (Locked)" as Leased + state "Spent (Archive)" as Spent + state "Dead (Invalid)" as Dead + + [*] --> Unspent : Created by Valid Tx + [*] --> Immature : Coinbase Confirmed + + Unspent --> Leased : Application Lock + Leased --> Unspent : Lock Expire/Release + + Unspent --> Spent : Used in New Tx + Leased --> Spent : Forced Spend + + Spent --> Unspent : Reorg (Spending Tx Invalidated) + + %% Coinbase maturity + Immature --> Unspent : Matures (100 blocks) + + %% Parent validity changes (derived, not persisted) + Unspent --> Dead : Parent Invalidated + Immature --> Dead : Parent Orphaned + Dead --> Immature : Parent Reconfirmed (Coinbase) +``` + +### 4.2 Derivation Logic + +A UTXO does not store its own status field. Its status is calculated dynamically to ensure consistency. + +| UTXO State | Parent Tx Status | `BlockHeight` | `SpentBy` Pointer | Meaning | +| :--- | :--- | :--- | :--- | :--- | +| **Confirmed** | `published` | `NOT NULL` | `NULL` | Available, mature funds. | +| **Unconfirmed** | `published` | `NULL` | `NULL` | Incoming funds, risk of RBF. | +| **Immature** | `published` (Coinbase) | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | +| **Spent** | `published` | `NOT NULL` | `NOT NULL` | History. We used this money. | +| **Dead (Permanent)** | `failed` / `replaced` | *Any* | *Any* | Permanently invalid output from a failed attempt. | +| **Dead (Recoverable)** | `orphaned` (coinbase) | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | + +--- + +## 5. Operational Behaviors + +### 5.1 Reorg Handling (Auto-Healing) + +```mermaid +graph TD + Block[Block N - Orphaned] -.->|Invalidates| Tx[Transaction] + + Tx -->|State becomes Unconfirmed| Output[Created UTXO] + Output -.->|State becomes Unconfirmed| Balance[Wallet Balance] + + Tx -->|Releases| Input[Spent UTXO] + Input -.->|State becomes Unspent| Balance +``` + +When the blockchain reorganizes (disconnects blocks): +1. **Block** record is disconnected from the best chain (deleted/replaced). +2. **Transactions** in that block have their `BlockHeight` set to `NULL`. +3. **Effect (non-coinbase):** Regular transactions revert to `Unconfirmed` state (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (as per ADR 0006) instead of reverting to `Unconfirmed`, since they cannot exist outside of the block that created them. +4. **Cascading Effect:** + * Outputs created by these txs become `Unconfirmed` (except for outputs of `orphaned` coinbase txs, which are no longer considered part of the spendable UTXO set). + * Inputs spent by these txs revert to `Unspent` (if the spending tx is completely invalidated). + +Implementation note: The SQL `blocks` table models the current best chain. During a disconnect, the best-chain association is removed (for example by deleting the `blocks` row at that height and inserting the new one). The coinbase status update MUST be atomic with clearing `BlockHeight` to avoid an invalid "unconfirmed coinbase" state. + +Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status='published'` atomically (one SQL statement within a transaction) to satisfy coinbase invariants. + +Reconfirmation semantics: +* The transaction keeps the same `tx_hash` and is associated with a new `block_height`. +* Outputs created by the transaction transition from `Dead (Recoverable)` back to `Immature` (and later become spendable after maturity). + +Deep reorg note: A deep reorg is handled as a batch of block disconnects. Implementations should treat this as a bounded, transactional operation (potentially chunked) rather than assuming a single-block example. + +### 5.2 RBF (Replace-By-Fee) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxB[Tx B - New] + TxB -->|Status: Published| Network + + Input -.->|Was Spent By| TxA[Tx A - Old] + TxA -->|Status: Replaced| Archive +``` + +When the wallet detects a replacement transaction (Tx B) for an existing unconfirmed transaction (Tx A): +1. **Detection:** Tx B spends the same inputs as Tx A. +2. **Update:** The input UTXOs are updated to point to Tx B (`SpentBy = TxB`). +3. **Archive:** Tx A is marked as `status = 'replaced'`. It remains in the DB for history but is ignored by balance queries. + +### 5.3 Upstream Double-Spend (Invalidation) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxD[Tx D - Competitor] + TxD -->|Status: Confirmed| Blockchain + + Input -.->|Was Spent By| TxC[Tx C - Ours] + TxC -->|Status: Failed| Archive + + TxC -->|Creates| Output[Output UTXO] + Output -.->|Status: Dead| Archive +``` + +When a transaction (Tx C) becomes invalid because *its input* was spent by a conflicting transaction (Tx D) that confirmed: +1. **Detection:** We see Tx D confirmed in a block. It spends UTXO X. +2. **Conflict:** We have Tx C (unconfirmed) which also spends UTXO X. +3. **Resolution:** + * Update UTXO X: `SpentBy = TxD`. + * Mark Tx C: `Status = 'failed'`. + * **Cascade:** Any UTXOs created by Tx C are now effectively "Dead" (because their parent is `failed`). + * Any transactions spending those dead UTXOs must also be marked `failed` (recursive invalidation). + +Architectural consequence: Recursive invalidation can require graph traversal across transaction chains. Typical wallet histories are shallow, but the design should acknowledge worst-case depth and define an execution strategy (batch processing, recursion limits, and/or iterative traversal) to avoid pathological performance. + +Suggested implementation strategy: +1. Start with a queue of newly-invalid transactions (double-spent, replaced, or orphaned). +2. Mark those transactions `failed`/`replaced`/`orphaned`. +3. Find UTXOs created by those transactions. +4. For each such UTXO, find transactions that spend it and enqueue them. +5. Repeat until the queue is empty. + +This can be implemented with an application-side loop (portable across databases) or a database-side recursive CTE (PostgreSQL). + +### 5.4 Coin Selection +Coin selection queries focus purely on the UTXO set: +1. Filter by `SpentBy IS NULL`. +2. Join Parent Transaction to ensure `Status IN ('published', 'pending')`. + * *Note:* Including `pending` allows chaining unbroadcast transactions (Zero-Latency Chaining), but creates a strict dependency: the parent must be broadcast before the child. + * This is an advanced feature and should be opt-in for conservative policies. + * If confirmation is required (e.g., for safety), add `AND BlockHeight IS NOT NULL`. +3. Filter out active `Leases`. + +Important: The `spendable_utxos` view described in ADR 0006 does not exclude leases (because it depends on the database's current time function and requires a `NOT EXISTS` against `utxo_leases`). Coin selection must explicitly filter `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`. + +Transaction reconstruction note: This model optimizes the high-frequency UTXO read path. Reconstructing a full transaction view (inputs/outputs for UI/history) is inherently more join-heavy and should be treated as a lower-frequency, separately optimized read path. + +### 5.5 Multi-Wallet Concurrency & Consistency +When multiple wallets share a single database, all `wtxmgr` rows are scoped by `wallet_id` (see ADR 0006). This avoids conflicts and makes correctness properties explicit. + +* **Consistency model:** The system assumes ACID semantics from the database. Reorg handling, status transitions, and lease acquisition must be performed as explicit SQL transactions. +* **Concurrency primitive:** UTXO leases are the wallet-level lock. Coin selection must exclude leased UTXOs, and lease acquisition should be treated as part of the same atomic unit as input selection. +* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined place (query helper/view + height parameter) to avoid accidental selection of immature funds. + +Suggested enforcement: a parameterized query helper or SQL function that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. From 5a0a0fe3eb469c01586919b59f3b8066975fc9f3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:13:12 +0800 Subject: [PATCH 104/695] itest: simplify case runner and add shuffle --- itest/main_test.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/itest/main_test.go b/itest/main_test.go index 1f01854c38..1549e3f504 100644 --- a/itest/main_test.go +++ b/itest/main_test.go @@ -5,11 +5,14 @@ package itest import ( "flag" "fmt" + "math/rand" "strings" "testing" "time" + "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/chain/port" ) var ( @@ -31,14 +34,34 @@ var ( "db", "kvdb", "database backend to use (kvdb, sqlite, postgres)", ) + + // shuffleSeedFlag is the source of randomness used to shuffle the test + // cases. If not specified, the test cases won't be shuffled. + shuffleSeedFlag = flag.Uint64( + "shuffleseed", 0, + "if set, shuffles the test cases using this as the source of "+ + "randomness", + ) ) +func init() { + // Use system-unique ports for rpctest harnesses so multiple local test runs + // don't collide. + rpctest.ListenAddressGenerator = func() (string, string) { + p2p := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + rpc := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + return p2p, rpc + } +} + // TestBtcWallet runs the btcwallet integration test suite. func TestBtcWallet(t *testing.T) { if len(allTestCases) == 0 { t.Skip("no integration test cases registered") } + maybeShuffleTestCases() + harness := bwtest.SetupHarness(t, *chainBackend, *dbBackend) for _, tc := range allTestCases { @@ -63,6 +86,20 @@ func TestBtcWallet(t *testing.T) { } } +// maybeShuffleTestCases shuffles the test cases if the flag `shuffleseed` is +// set and not 0. +func maybeShuffleTestCases() { + // Exit if set to 0. + if *shuffleSeedFlag == 0 { + return + } + + r := rand.New(rand.NewSource(int64(*shuffleSeedFlag))) + r.Shuffle(len(allTestCases), func(i, j int) { + allTestCases[i], allTestCases[j] = allTestCases[j], allTestCases[i] + }) +} + // validateTestCaseName enforces a consistent naming convention for integration // test cases. // From c77c085e65c9ee46107a53c1d6ed4334c19f0ea4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:13:29 +0800 Subject: [PATCH 105/695] build: update itest backend vars and filtering --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index e34fd8f86b..5e6fe85144 100644 --- a/Makefile +++ b/Makefile @@ -137,7 +137,7 @@ itest-db-race: env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) #? itest: Run integration tests -#? itest (vars): chain=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres +#? itest (vars): chain=btcd|bitcoind|neutrino backend=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres #? itest (vars): icase= (filter itest cases) #? itest (vars): timeout= verbose=1 nocache=1 #? itest (ex): make itest icase=manager @@ -146,10 +146,10 @@ itest: @$(call print, "Running integration tests.") @$(GOTEST) -v ./itest \ -tags="itest $(DEV_TAGS) nolog" \ - $(if $(icase),-test.run="TestBtcWallet/.*/$(icase)",) \ + $(if $(icase),-test.run="TestBtcWallet$$|$(icase)",) \ $(filter-out -test.run=%,$(TEST_FLAGS)) \ -args \ - -chain="$(if $(chain),$(chain),btcd)" \ + -chain="$(if $(backend),$(backend),$(if $(chain),$(chain),btcd))" \ -db="$(if $(db),$(db),kvdb)" # ========= From 96021fe127dce18b5fd48e86d26db9946f8ea6d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:49:32 +0800 Subject: [PATCH 106/695] bwtest: refactor itest harness and log capture --- bwtest/backend.go | 83 +++++++++++++++++ bwtest/btcd.go | 138 +++++++++++++++++++++++++++++ bwtest/chain_backend.go | 133 ---------------------------- bwtest/doc.go | 3 + bwtest/harness.go | 118 ++++++++++++++++++++----- bwtest/harness_miner.go | 5 +- bwtest/logdir.go | 158 +++++++++++++++++++++++++++++++++ bwtest/logs_finalize.go | 186 +++++++++++++++++++++++++++++++++++++++ bwtest/miner.go | 80 ++--------------- bwtest/perms.go | 7 ++ bwtest/utils.go | 11 +++ bwtest/wallet_logging.go | 78 ++++++++++++++++ 12 files changed, 767 insertions(+), 233 deletions(-) create mode 100644 bwtest/backend.go create mode 100644 bwtest/btcd.go delete mode 100644 bwtest/chain_backend.go create mode 100644 bwtest/doc.go create mode 100644 bwtest/logdir.go create mode 100644 bwtest/logs_finalize.go create mode 100644 bwtest/perms.go create mode 100644 bwtest/wallet_logging.go diff --git a/bwtest/backend.go b/bwtest/backend.go new file mode 100644 index 0000000000..8b1035b575 --- /dev/null +++ b/bwtest/backend.go @@ -0,0 +1,83 @@ +package bwtest + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcwallet/chain" +) + +const ( + backendBtcd = "btcd" + backendBitcoind = "bitcoind" + backendNeutrino = "neutrino" +) + +var ( + errMissingMinerAddr = errors.New("missing miner address") +) + +// ChainBackend defines the interface that all chain backends must implement. +// +// A ChainBackend instance is shared across the whole itest suite run. +// Implementations must be safe to reuse across subtests that run serially. +type ChainBackend interface { + // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). + Name() string + + // Start launches the chain backend. + Start() error + + // Stop shuts down the chain backend. + Stop() error + + // ConnectMiner connects this backend to the miner. + ConnectMiner(minerAddr string) error + + // NewChainClient creates a new chain.Interface instance backed by this + // backend. + // + // This is expected to be called once for each subtest. The returned cleanup + // function must stop and release all resources created by the client. + NewChainClient(ctx context.Context) (chain.Interface, func(), error) + + // LogDir returns the directory where the backend writes its logs (if any). + LogDir() string +} + +// NewBackend creates a ChainBackend based on the type string. +func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { + t.Helper() + + switch backendType { + case backendBtcd: + return NewBtcdBackend(t, logDir) + + case backendBitcoind, backendNeutrino: + t.Fatalf("chain backend %q is not implemented yet", backendType) + return nil + + default: + t.Fatalf("unknown chain backend %q", backendType) + return nil + } +} + +// validateBackendType validates the chain backend identifier provided by test +// flags before backend construction starts. +func validateBackendType(t *testing.T, backendType string) { + t.Helper() + + switch backendType { + case backendBtcd: + return + + case backendBitcoind, backendNeutrino: + t.Fatalf("chain backend %q is not implemented yet", backendType) + return + + default: + t.Fatalf("unknown chain backend %q", backendType) + } +} diff --git a/bwtest/btcd.go b/bwtest/btcd.go new file mode 100644 index 0000000000..205def545e --- /dev/null +++ b/bwtest/btcd.go @@ -0,0 +1,138 @@ +package bwtest + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/chain" + "github.com/stretchr/testify/require" +) + +// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). +type BtcdBackend struct { + // harness is the underlying rpctest harness that manages the btcd process. + harness *rpctest.Harness + + // logDir is the directory where btcd writes its logs. + logDir string + + // minerAddr is the P2P address of the shared miner. + minerAddr string +} + +// NewBtcdBackend creates a new BtcdBackend. +func NewBtcdBackend(t *testing.T, logDir string) *BtcdBackend { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create btcd backend log dir") + + // Create a separate harness for the chain backend. + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + handlers := &rpcclient.NotificationHandlers{} + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create btcd backend harness") + + return &BtcdBackend{ + harness: harness, + logDir: logDir, + } +} + +// Name returns the identifier of the backend. +func (b *BtcdBackend) Name() string { + return backendBtcd +} + +// Start launches the backend daemon. +func (b *BtcdBackend) Start() error { + // SetUp(false, 0) means we don't treat it as a miner and don't cache block + // templates. + err := b.harness.SetUp(false, 0) + if err != nil { + return fmt.Errorf("setup btcd harness: %w", err) + } + + if b.minerAddr == "" { + return fmt.Errorf("btcd: %w", errMissingMinerAddr) + } + + // Connect the backend to the miner after the node is up. + err = b.harness.Client.AddNode(b.minerAddr, "add") + if err != nil { + return fmt.Errorf("add miner node %s: %w", b.minerAddr, err) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BtcdBackend) Stop() error { + err := b.harness.TearDown() + if err != nil { + return fmt.Errorf("teardown btcd harness: %w", err) + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BtcdBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new RPC-backed chain.Interface connected to this +// backend. +func (b *BtcdBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + backendCfg := b.harness.RPCConfig() + rpcCfg := backendCfg + + chainConfig := &chain.RPCClientConfig{ + Conn: &rpcCfg, + Chain: harnessNetParams, + ReconnectAttempts: defaultChainReconnectAttempts, + } + + chainClient, err := chain.NewRPCClientWithConfig(chainConfig) + if err != nil { + return nil, nil, fmt.Errorf("create chain client: %w", err) + } + + err = chainClient.Start(ctx) + if err != nil { + return nil, nil, fmt.Errorf("start chain client: %w", err) + } + + cleanup := func() { + chainClient.Stop() + } + + return chainClient, cleanup, nil +} + +// LogDir returns the directory where btcd wrote its logs for this run. +func (b *BtcdBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BtcdBackend)(nil) diff --git a/bwtest/chain_backend.go b/bwtest/chain_backend.go deleted file mode 100644 index c6a8eb2d76..0000000000 --- a/bwtest/chain_backend.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package bwtest provides the integration test framework for btcwallet. -package bwtest - -import ( - "fmt" - "testing" - - "github.com/btcsuite/btcd/integration/rpctest" - "github.com/btcsuite/btcd/rpcclient" - "github.com/stretchr/testify/require" -) - -// ChainBackend defines the interface that all chain backends must implement. -type ChainBackend interface { - // Start launches the chain backend process. - Start() error - - // Stop shuts down the chain backend process. - Stop() error - - // RPCConfig returns the credentials to connect to this backend. - RPCConfig() rpcclient.ConnConfig - - // P2PAddr returns the P2P address of this node. - P2PAddr() string - - // ConnectMiner connects this node to the miner. - ConnectMiner(minerAddr string) error - - // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). - Name() string -} - -// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). -type BtcdBackend struct { - // harness is the underlying rpctest harness that manages the btcd process. - harness *rpctest.Harness -} - -// NewBtcdBackend creates a new BtcdBackend. -func NewBtcdBackend(t *testing.T) *BtcdBackend { - t.Helper() - - btcdBinary, err := GetBtcdBinary() - require.NoError(t, err, "unable to find btcd binary") - - // Create a separate harness for the chain backend. - // We use the same regression net params. - args := []string{ - "--rejectnonstd", // Reject non-standard txs in tests. - "--txindex", // Required for some RPC queries. - "--nowinservice", // Avoid Windows service integration. - "--nobanning", // Avoid peer banning in local tests. - "--debuglevel=debug", // Provide detailed logs for debugging. - "--trickleinterval=100ms", // Speed up inv relay in regtest. - "--nostalldetect", // Avoid stall detection flakiness. - } - - handlers := &rpcclient.NotificationHandlers{} - harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) - require.NoError(t, err, "unable to create btcd backend harness") - - return &BtcdBackend{ - harness: harness, - } -} - -// Start launches the btcd node. -func (b *BtcdBackend) Start() error { - // SetUp(false, 0) means we don't treat it as a miner - // (no mining addrs needed immediately) and don't cache block templates. - err := b.harness.SetUp(false, 0) - if err != nil { - return fmt.Errorf("failed to setup btcd harness: %w", err) - } - - return nil -} - -// Stop shuts down the btcd node. -func (b *BtcdBackend) Stop() error { - err := b.harness.TearDown() - if err != nil { - return fmt.Errorf("failed to teardown btcd harness: %w", err) - } - - return nil -} - -// RPCConfig returns the RPC connection config. -func (b *BtcdBackend) RPCConfig() rpcclient.ConnConfig { - return b.harness.RPCConfig() -} - -// P2PAddr returns the P2P address. -func (b *BtcdBackend) P2PAddr() string { - return b.harness.P2PAddress() -} - -// ConnectMiner connects the backend to the miner. -func (b *BtcdBackend) ConnectMiner(minerAddr string) error { - // We use "add" to make it persistent. - err := b.harness.Client.AddNode(minerAddr, "add") - if err != nil { - return fmt.Errorf("failed to add miner node %s: %w", minerAddr, - err) - } - - return nil -} - -// Name returns "btcd". -func (b *BtcdBackend) Name() string { - return "btcd" -} - -// Ensure BtcdBackend implements ChainBackend. -var _ ChainBackend = (*BtcdBackend)(nil) - -// NewBackend creates a ChainBackend based on the type string. -// Currently only supports "btcd". -func NewBackend(t *testing.T, backendType string) ChainBackend { - t.Helper() - - switch backendType { - case "btcd": - return NewBtcdBackend(t) - // TODO: Add bitcoind and neutrino support. - default: - t.Fatalf("unknown backend type: %s", backendType) - return nil - } -} diff --git a/bwtest/doc.go b/bwtest/doc.go new file mode 100644 index 0000000000..bf125c105d --- /dev/null +++ b/bwtest/doc.go @@ -0,0 +1,3 @@ +// Package bwtest contains the integration test harness used by the itest +// package. +package bwtest diff --git a/bwtest/harness.go b/bwtest/harness.go index d9e87507f0..13ca4e0203 100644 --- a/bwtest/harness.go +++ b/bwtest/harness.go @@ -1,12 +1,14 @@ package bwtest import ( + "context" + "fmt" + "path/filepath" "runtime/debug" "sync" "testing" "github.com/btcsuite/btcd/chaincfg/v2" - "github.com/btcsuite/btcwallet/bwtest/wait" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/wallet" "github.com/btcsuite/btcwallet/walletdb" @@ -23,6 +25,9 @@ const ( type HarnessTest struct { *testing.T + // logDir is the per-run root log directory. + logDir string + // miner is the shared mining node used to generate blocks. miner *minerHarness @@ -50,22 +55,39 @@ type HarnessTest struct { // stopped prevents stopping shared infrastructure more than once. stopped bool + + // cleaned indicates the subtest cleanup has already run. + cleaned bool } // SetupHarness creates a new HarnessTest. func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { t.Helper() + logDir := createTestLogDir(t, chainBackendType, dbType) + // 1. Start Miner (always btcd). - miner := newMiner(t) + minerLogDir := createOrEnsureLogSubDir(t, logDir, "miner") + miner := newMiner(t, minerLogDir) miner.SetUp() // 2. Start Chain Backend. - backend := NewBackend(t, chainBackendType) + backendLogDir := "" + + // Neutrino runs in-process and has no separate daemon log directory. The + // external daemon backends (btcd/bitcoind) each get a dedicated backend log + // sub-directory. + if chainBackendType != backendNeutrino { + backendLogDir = createOrEnsureLogSubDir(t, logDir, "chain-backend") + } + + backend := NewBackend(t, chainBackendType, backendLogDir) + require.NoError(t, backend.ConnectMiner(miner.P2PAddress())) require.NoError(t, backend.Start(), "failed to start chain backend") ht := &HarnessTest{ T: t, + logDir: logDir, miner: miner, Backend: backend, dbType: dbType, @@ -74,13 +96,6 @@ func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { // Ensure the harness is cleaned up when the test finishes. t.Cleanup(ht.Stop) - // 3. Connect Backend to Miner. - // Backend startup can take a moment, so we retry until it succeeds. - err := wait.NoError(func() error { - return backend.ConnectMiner(miner.P2PAddress()) - }, defaultTestTimeout) - require.NoError(t, err, "failed to connect backend to miner") - return ht } @@ -94,6 +109,7 @@ func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { st := &HarnessTest{ T: t, + logDir: h.logDir, miner: h.miner, Backend: h.Backend, dbType: h.dbType, @@ -112,9 +128,42 @@ func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { // is refactored. st.miner.T = st.T + walletLogCleanup := setUpWalletLogging( + t, filepath.Join(st.logDir, walletLogFileName(t)), + ) + st.Cleanup(walletLogCleanup) + st.setUpChainClient() st.setUpWalletDB() + st.Cleanup(func() { + // If a test fails, we still try to stop wallets to avoid leaking + // goroutines into the next test, but we skip assertions. + if st.Failed() { + err := st.stopActiveWallets(context.Background()) + if err != nil { + st.Logf("failed to stop wallets during failed-test cleanup: %v", + err) + } + + return + } + + if st.cleaned { + return + } + + err := st.stopActiveWallets(context.Background()) + require.NoError(st, err, "failed to stop wallets") + + mempool, err := st.getRawMempool() + require.NoError(st, err, "failed to query miner mempool") + require.Empty(st, mempool, "mempool not cleaned") + + st.cleaned = true + h.cleaned = true + }) + return st } @@ -199,28 +248,49 @@ func (h *HarnessTest) Stop() { // Finally, stop the miner. h.miner.Stop() + + // Flatten logs into the per-run log dir. + h.finalizeLogs() } -// setUpChainClient creates and starts an RPC chain client for the active -// harness backend. -func (h *HarnessTest) setUpChainClient() { +// stopActiveWallets stops all wallets registered with the harness. +// +// This is used as part of the per-subtest cleanup to avoid leaking background +// goroutines into the next test. +func (h *HarnessTest) stopActiveWallets(ctx context.Context) error { h.Helper() - backendCfg := h.Backend.RPCConfig() - rpcCfg := backendCfg + for _, w := range h.ActiveWallets() { + if w == nil { + // Keep cleanup robust against partially initialized test state. A + // caller could register a wallet reference and fail before the + // assignment completes. + continue + } - chainConfig := &chain.RPCClientConfig{ - Conn: &rpcCfg, - Chain: harnessNetParams, - ReconnectAttempts: defaultChainReconnectAttempts, + // The modern Wallet controller's Stop method is idempotent. + // + // NOTE: We intentionally don't call the deprecated WaitForShutdown/ + // ShuttingDown methods here, as modern wallets might not have the + // legacy fields initialized. + err := w.Stop(ctx) + if err != nil { + return fmt.Errorf("stop wallet: %w", err) + } } - chainClient, err := chain.NewRPCClientWithConfig(chainConfig) - require.NoError(h, err, "unable to create chain client") - err = chainClient.Start(h.Context()) - require.NoError(h, err, "unable to start chain client") - h.Cleanup(chainClient.Stop) + return nil +} + +// setUpChainClient creates and starts a chain client for the active harness +// backend. +func (h *HarnessTest) setUpChainClient() { + h.Helper() + + chainClient, cleanup, err := h.Backend.NewChainClient(h.Context()) + require.NoError(h, err, "unable to create chain client") + h.Cleanup(cleanup) h.ChainClient = chainClient } diff --git a/bwtest/harness_miner.go b/bwtest/harness_miner.go index 421da225b6..8691050b99 100644 --- a/bwtest/harness_miner.go +++ b/bwtest/harness_miner.go @@ -319,10 +319,11 @@ func (h *HarnessTest) MineBlockWithTx(tx *wire.MsgTx) *wire.MsgBlock { func (h *HarnessTest) SpawnTempMiner() *HarnessTest { h.Helper() - tempMiner := newMiner(h.T) + minerLogDir := createUniqueLogSubDir(h.T, h.logDir, "miner-temp") + tempMiner := newMiner(h.T, minerLogDir) tempMiner.SetUpNoChain() - th := &HarnessTest{T: h.T, miner: tempMiner} + th := &HarnessTest{T: h.T, logDir: h.logDir, miner: tempMiner} h.Cleanup(tempMiner.Stop) // Connect the miners and wait for the temp miner to sync. diff --git a/bwtest/logdir.go b/bwtest/logdir.go new file mode 100644 index 0000000000..17c7a72fdf --- /dev/null +++ b/bwtest/logdir.go @@ -0,0 +1,158 @@ +package bwtest + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + // testLogsRootDir is the directory under the itest package where all + // per-run logs are stored. + // + // Note: When running the integration tests with `go test ./itest`, the + // working directory is `itest`, so logs are written under + // `itest/test-logs`. + testLogsRootDir = "test-logs" + + maxLogDirAttempts = 1000 +) + +// createTestLogDir creates a per-run log directory under `test-logs`. +// +// The directory is named using the format: +// +// log---YYYYMMDD-HHMMSS +// +// If the directory already exists, a numeric suffix is appended. +func createTestLogDir(t *testing.T, chainBackend, dbBackend string) string { + t.Helper() + + err := os.MkdirAll(testLogsRootDir, logDirPerm) + require.NoError(t, err, "unable to create test log root") + + chainBackend = sanitizeLogToken(chainBackend) + dbBackend = sanitizeLogToken(dbBackend) + + base := fmt.Sprintf( + "log-%s-%s-%s", chainBackend, dbBackend, + time.Now().Format("20060102-150405"), + ) + + // Use Mkdir instead of MkdirAll so we can detect collisions and retry + // with a deterministic numeric suffix. + for i := range maxLogDirAttempts { + dir := base + if i > 0 { + dir = fmt.Sprintf("%s-%d", base, i) + } + + fullPath := filepath.Join(testLogsRootDir, dir) + + err := os.Mkdir(fullPath, logDirPerm) + if err == nil { + _, _ = fmt.Fprintf(os.Stdout, "itest logs dir: %s\n", fullPath) + return fullPath + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create test log dir") + } + + t.Fatalf( + "unable to create test log dir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} + +// sanitizeLogToken converts a string into a safe filename token. +func sanitizeLogToken(token string) string { + if token == "" { + return "unknown" + } + + var b strings.Builder + for _, r := range token { + if isSafeLogRune(r) { + b.WriteRune(r) + continue + } + + b.WriteByte('_') + } + + return b.String() +} + +// isSafeLogRune reports whether r can be used in log directory/file names +// without additional escaping. +func isSafeLogRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '-' || r == '_': + return true + default: + return false + } +} + +// createOrEnsureLogSubDir creates a named sub-directory under a per-run log +// directory. +func createOrEnsureLogSubDir(t *testing.T, parent, name string) string { + t.Helper() + + full := filepath.Join(parent, name) + err := os.MkdirAll(full, logDirPerm) + require.NoError(t, err, "unable to create log subdir") + + return full +} + +// createUniqueLogSubDir creates a uniquely named sub-directory under a per-run +// log directory. +func createUniqueLogSubDir(t *testing.T, parent, prefix string) string { + t.Helper() + + // Retry with a numeric suffix when a directory name collision occurs. + for i := range maxLogDirAttempts { + dir := prefix + if i > 0 { + dir = fmt.Sprintf("%s-%d", prefix, i) + } + + full := filepath.Join(parent, dir) + + err := os.Mkdir(full, logDirPerm) + if err == nil { + return full + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create log subdir") + } + + t.Fatalf( + "unable to create log subdir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} diff --git a/bwtest/logs_finalize.go b/bwtest/logs_finalize.go new file mode 100644 index 0000000000..813056e15f --- /dev/null +++ b/bwtest/logs_finalize.go @@ -0,0 +1,186 @@ +package bwtest + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + minerLogFilename = "miner.log" + chainBackendLogFilename = "chain_backend.log" + + logFilePerm = 0o600 +) + +// finalizeLogs flattens component logs into the per-run log directory. +func (h *HarnessTest) finalizeLogs() { + h.Helper() + + // Flatten miner logs. + minerDst := filepath.Join(h.logDir, minerLogFilename) + + err := flattenBtcdLogs(h.T, h.miner.logPath, minerDst) + if err != nil { + h.Logf("failed to flatten miner logs: %v", err) + } + + chainLogDir := h.Backend.LogDir() + + chainDst := filepath.Join(h.logDir, chainBackendLogFilename) + if chainLogDir == "" { + // Some backends (eg. neutrino) do not have an external process log + // directory. Still create the file for consistent log collection. + // #nosec G304 -- chainDst is created by the test harness. + f, err := os.OpenFile( + chainDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFilePerm, + ) + if err != nil { + h.Logf("failed to create chain backend log file: %v", err) + return + } + + _ = f.Close() + + return + } + + switch h.Backend.Name() { + case backendBtcd: + err = flattenBtcdLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten btcd backend logs: %v", err) + } + + default: + // No backend logs to flatten. + } +} + +// flattenBtcdLogs concatenates btcd logs under srcDir into dstFile. +func flattenBtcdLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + pattern := filepath.Join(srcDir, "*", "btcd.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob btcd logs: %w", err) + } + + if len(matches) == 0 { + return nil + } + + files := filterRegularFiles(matches) + if len(files) == 0 { + return nil + } + + sortBtcdLogs(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Add a small delimiter between rotated files. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + // Best effort cleanup to keep the log dir shallow. + _ = os.RemoveAll(srcDir) + + return nil +} + +// filterRegularFiles filters to existing regular files. +func filterRegularFiles(paths []string) []string { + files := make([]string, 0, len(paths)) + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + continue + } + + if info.Mode().IsRegular() { + files = append(files, p) + } + } + + return files +} + +// sortBtcdLogs sorts btcd logs by rotation index so older logs appear first. +func sortBtcdLogs(paths []string) { + sort.Slice(paths, func(i, j int) bool { + iBase := filepath.Base(paths[i]) + jBase := filepath.Base(paths[j]) + + // Prefer older rotated logs first: btcd.log.N ... btcd.log.1 then + // btcd.log. + iN, iOk := btcdLogRotationIndex(iBase) + + jN, jOk := btcdLogRotationIndex(jBase) + if iOk && jOk { + // Larger rotation index means an older file. For example, + // btcd.log.3 is older than btcd.log.1 and should be concatenated + // first. + return iN > jN + } + + if iOk != jOk { + // Rotated logs before the active log. + return iOk + } + + // Fallback to lexicographic ordering. + return iBase < jBase + }) +} + +// btcdLogRotationIndex parses the rotation suffix of btcd log filenames. +func btcdLogRotationIndex(base string) (int, bool) { + // btcd rotates logs like btcd.log.1, btcd.log.2, ... + const prefix = "btcd.log." + if !strings.HasPrefix(base, prefix) { + return 0, false + } + + n, err := strconv.Atoi(strings.TrimPrefix(base, prefix)) + if err != nil { + return 0, false + } + + return n, true +} diff --git a/bwtest/miner.go b/bwtest/miner.go index c542dc5138..4f50ec425e 100644 --- a/bwtest/miner.go +++ b/bwtest/miner.go @@ -1,11 +1,7 @@ package bwtest import ( - "fmt" - "os" - "path/filepath" "testing" - "time" "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/integration/rpctest" @@ -14,16 +10,6 @@ import ( ) const ( - // MinerLogFilename is the default log filename for the miner node. - MinerLogFilename = "output_btcd_miner.log" - - // MinerLogDir is the default log dir for the miner node. - // - // Note: When running the integration tests with `go test ./itest`, the - // working directory is `itest`, so logs are written under - // `itest/test-logs`. - MinerLogDir = "test-logs" - // minerSetupOutputs is the number of outputs to generate during miner // setup. minerSetupOutputs = 50 @@ -39,13 +25,6 @@ const ( // minerWindowMultiplier is the multiplier for the miner confirmation // window to ensure we mine enough blocks for activation. minerWindowMultiplier = 2 - - // minerLogDirPerm is the file mode used when creating the miner log dir. - minerLogDirPerm = 0o750 - - // maxMinerLogDirAttempts is the maximum number of attempts to create a - // unique log directory. - maxMinerLogDirAttempts = 1000 ) var ( @@ -62,19 +41,17 @@ type minerHarness struct { // logPath is the directory path of the miner's logs. logPath string - - // logFilename is the saved log filename of the miner node. - logFilename string } // newMiner creates a new minerHarness instance. -func newMiner(t *testing.T) *minerHarness { +func newMiner(t *testing.T, logDir string) *minerHarness { t.Helper() btcdBinary, err := GetBtcdBinary() require.NoError(t, err, "unable to find btcd binary") - logDir := createMinerLogDir(t) + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create miner log dir") args := []string{ "--rejectnonstd", // Reject non-standard txs in tests. @@ -95,56 +72,14 @@ func newMiner(t *testing.T) *minerHarness { require.NoError(t, err, "unable to create rpctest harness") m := &minerHarness{ - T: t, - Harness: harness, - logPath: logDir, - logFilename: MinerLogFilename, + T: t, + Harness: harness, + logPath: logDir, } return m } -// createMinerLogDir creates a per-run log directory for the miner. -// -// The directory is named using the format log-YYYYMMDD-HHMMSS. If the -// directory already exists, a numeric suffix is appended. -func createMinerLogDir(t *testing.T) string { - t.Helper() - - // Ensure the log root exists. - err := os.MkdirAll(MinerLogDir, minerLogDirPerm) - require.NoError(t, err, "unable to create miner log root") - - base := "log-" + time.Now().Format("20060102-150405") - - for i := range maxMinerLogDirAttempts { - dir := base - if i > 0 { - dir = fmt.Sprintf("%s-%d", base, i) - } - - fullPath := filepath.Join(MinerLogDir, dir) - - err := os.Mkdir(fullPath, minerLogDirPerm) - if err == nil { - return fullPath - } - - if os.IsExist(err) { - continue - } - - require.NoError(t, err, "unable to create miner log dir") - } - - t.Fatalf( - "unable to create miner log dir: too many collisions (%d)", - maxMinerLogDirAttempts, - ) - - return "" -} - // SetUp starts the miner node and generates initial blocks to activate SegWit. func (m *minerHarness) SetUp() { m.Helper() @@ -196,7 +131,4 @@ func (m *minerHarness) SetUpNoChain() { // Stop shuts down the miner. func (m *minerHarness) Stop() { require.NoError(m, m.TearDown(), "tear down miner failed") - - // Always keep logs for debugging, even for passing tests. - m.Logf("Miner logs available at: %s", m.logPath) } diff --git a/bwtest/perms.go b/bwtest/perms.go new file mode 100644 index 0000000000..3db2c9ffc7 --- /dev/null +++ b/bwtest/perms.go @@ -0,0 +1,7 @@ +package bwtest + +// logDirPerm is the default permission for harness-managed log directories. +// +// 0o750 keeps logs accessible to the current user/group while avoiding +// world-readable test artifacts that may contain sensitive runtime details. +const logDirPerm = 0o750 diff --git a/bwtest/utils.go b/bwtest/utils.go index baf7ede64c..7a5e0485a3 100644 --- a/bwtest/utils.go +++ b/bwtest/utils.go @@ -2,6 +2,7 @@ package bwtest import ( "fmt" + "os" "os/exec" "path/filepath" ) @@ -37,3 +38,13 @@ func GetBitcoindBinary() (string, error) { return path, nil } + +// ensureLogDir creates the log directory if it doesn't exist. +func ensureLogDir(dir string) error { + err := os.MkdirAll(dir, logDirPerm) + if err != nil { + return fmt.Errorf("mkdir log dir: %w", err) + } + + return nil +} diff --git a/bwtest/wallet_logging.go b/bwtest/wallet_logging.go new file mode 100644 index 0000000000..a623644402 --- /dev/null +++ b/bwtest/wallet_logging.go @@ -0,0 +1,78 @@ +package bwtest + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btclog" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// walletLogFilePerm is intentionally more restrictive than logDirPerm because +// wallet logs may contain addresses, txids, and operational details that should +// not be readable by other users on a shared machine. +const walletLogFilePerm = 0o600 + +// setUpWalletLogging configures the btclog-based loggers used by btcwallet to +// write into the provided log file path. +// +// NOTE: This is package-global logger configuration. It should only be used in +// serial integration tests. +func setUpWalletLogging(t *testing.T, logPath string) func() { + t.Helper() + + // #nosec G304 -- logPath is created by the test harness. + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + walletLogFilePerm) + require.NoError(t, err, "unable to create wallet log file") + + backend := btclog.NewBackend(f) + + btwl := backend.Logger("BTWL") + amgr := backend.Logger("AMGR") + tmgr := backend.Logger("TMGR") + chio := backend.Logger("CHIO") + rpcl := backend.Logger("RPCC") + + level, _ := btclog.LevelFromString("debug") + btwl.SetLevel(level) + amgr.SetLevel(level) + tmgr.SetLevel(level) + chio.SetLevel(level) + rpcl.SetLevel(level) + + wallet.UseLogger(btwl) + waddrmgr.UseLogger(amgr) + wtxmgr.UseLogger(tmgr) + chain.UseLogger(chio) + rpcclient.UseLogger(rpcl) + + return func() { + _ = f.Sync() + _ = f.Close() + } +} + +// walletLogFileName returns the per-test wallet log filename. +func walletLogFileName(t *testing.T) string { + t.Helper() + + // Use the leaf subtest name to keep filenames short. + name := t.Name() + + parts := strings.Split(name, "/") + if len(parts) > 0 { + name = parts[len(parts)-1] + } + + name = sanitizeLogToken(name) + + return fmt.Sprintf("wallet-%s.log", name) +} From 22f5deae0dc14ae2f614627248181bfd2c1a29f9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:50:40 +0800 Subject: [PATCH 107/695] bwtest: add bitcoind backend --- bwtest/backend.go | 10 +- bwtest/bitcoind.go | 388 ++++++++++++++++++++++++++++++++++++++++ bwtest/logs_finalize.go | 77 ++++++++ bwtest/rlimit_other.go | 11 ++ bwtest/rlimit_unix.go | 83 +++++++++ 5 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 bwtest/bitcoind.go create mode 100644 bwtest/rlimit_other.go create mode 100644 bwtest/rlimit_unix.go diff --git a/bwtest/backend.go b/bwtest/backend.go index 8b1035b575..2428d676a0 100644 --- a/bwtest/backend.go +++ b/bwtest/backend.go @@ -54,7 +54,10 @@ func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { case backendBtcd: return NewBtcdBackend(t, logDir) - case backendBitcoind, backendNeutrino: + case backendBitcoind: + return NewBitcoindBackend(t, logDir) + + case backendNeutrino: t.Fatalf("chain backend %q is not implemented yet", backendType) return nil @@ -73,7 +76,10 @@ func validateBackendType(t *testing.T, backendType string) { case backendBtcd: return - case backendBitcoind, backendNeutrino: + case backendBitcoind: + return + + case backendNeutrino: t.Fatalf("chain backend %q is not implemented yet", backendType) return diff --git a/bwtest/bitcoind.go b/bwtest/bitcoind.go new file mode 100644 index 0000000000..b949df838a --- /dev/null +++ b/bwtest/bitcoind.go @@ -0,0 +1,388 @@ +package bwtest + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/chain/port" + "github.com/stretchr/testify/require" +) + +const ( + // bitcoindRPCUser/bitcoindRPCPass are test-only credentials used by the + // chain client. They match the static rpcauth entry below. + bitcoindRPCUser = "weks" + bitcoindRPCPass = "weks" + + // bitcoindLogFilePerm protects daemon stdout/stderr logs written by the + // harness. + bitcoindLogFilePerm = 0o600 + + // bitcoindZMQReadDeadline bounds how long we wait on each ZMQ read. + bitcoindZMQReadDeadline = 5 * time.Second + // bitcoindMempoolPollingInterval controls fallback mempool polling cadence. + bitcoindMempoolPollingInterval = 100 * time.Millisecond + // bitcoindMaxConnections keeps descriptor requirements low in CI. + bitcoindMaxConnections = 16 + // bitcoindMaxMempoolMB reduces memory usage for short-lived test runs. + bitcoindMaxMempoolMB = 50 + + // bitcoindRPCAuthorization enables RPC access with user/pass without + // storing cleartext credentials in the datadir. + // + // Generated with: bitcoind -rpcauth=weks:weks. + bitcoindRPCAuthorization = "weks:469e9bb14ab2360f8e226efed5ca6f" + + "d$507c670e800a95284294edb5773b05544b" + + "220110063096c221be9933c82d38e1" +) + +var ( + errBitcoindNotSynced = errors.New("bitcoind not synced") +) + +// BitcoindBackend is a ChainBackend backed by a bitcoind process. +type BitcoindBackend struct { + // binary is the resolved bitcoind executable path. + binary string + + // cmd is the running bitcoind process. + cmd *exec.Cmd + + // logDir is the bitcoind data directory used by this backend instance. + logDir string + + // rpcPort is the HTTP-RPC port used by bitcoind. + rpcPort int + // p2pPort is the inbound/outbound p2p port used by bitcoind. + p2pPort int + + // zmqBlockHost publishes raw block notifications for chain clients. + zmqBlockHost string + // zmqTxHost publishes raw transaction notifications for chain clients. + zmqTxHost string + + // minerAddr is the shared miner peer address that bitcoind connects to. + minerAddr string + + // stdoutPath/stderrPath are harness-managed daemon log files. + stdoutPath string + stderrPath string + + // stdoutFile/stderrFile stay open for the lifetime of the daemon process. + stdoutFile *os.File + stderrFile *os.File + + // cmdCancel cancels the process context to unblock shutdown paths. + cmdCancel context.CancelFunc +} + +// NewBitcoindBackend creates a new BitcoindBackend. +// +// The backend writes its stdout/stderr into the passed logDir and uses ZMQ for +// block and transaction notifications. +func NewBitcoindBackend(t *testing.T, logDir string) *BitcoindBackend { + t.Helper() + + bitcoindBinary, err := GetBitcoindBinary() + require.NoError(t, err, "unable to find bitcoind binary") + + absLogDir, err := filepath.Abs(logDir) + require.NoError(t, err, "unable to get absolute bitcoind log dir") + + err = ensureLogDir(absLogDir) + require.NoError(t, err, "unable to create bitcoind log dir") + + // Reserve ports in a stable order so diagnostics are easier to read when a + // setup step fails and reports one of these endpoints. + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() + rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() + + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) + + return &BitcoindBackend{ + binary: bitcoindBinary, + logDir: absLogDir, + rpcPort: rpcPort, + p2pPort: p2pPort, + zmqBlockHost: zmqBlockHost, + zmqTxHost: zmqTxHost, + stdoutPath: filepath.Join(absLogDir, "bitcoind.stdout.log"), + stderrPath: filepath.Join(absLogDir, "bitcoind.stderr.log"), + } +} + +// Name returns the identifier of the backend. +func (b *BitcoindBackend) Name() string { + return backendBitcoind +} + +// Start launches the backend daemon. +func (b *BitcoindBackend) Start() error { + // Startup sequence overview: + // 1. Validate harness wiring (miner address + process limits). + // 2. Build daemon arguments (regtest, rpc, zmq, resource limits). + // 3. Redirect stdout/stderr to harness-managed log files. + // 4. Start the daemon process and retain file handles. + // 5. Probe RPC until the node is responsive and chain-synced. + // + // If any step fails we return an error that points to the collected logs so + // CI failures can be diagnosed from artifacts. + if b.minerAddr == "" { + return fmt.Errorf("bitcoind: %w", errMissingMinerAddr) + } + + // Best-effort attempt to increase the file descriptor limit before starting + // bitcoind. This helps avoid startup failures on systems with a low default + // RLIMIT_NOFILE. + _ = raiseNoFileLimit() + + args := []string{ + // Core regtest + connectivity setup. + "-datadir=" + b.logDir, + "-regtest", + "-connect=" + b.minerAddr, + + // Enable wallet-required indexing and RPC auth. + "-txindex", + "-disablewallet", + "-rpcauth=" + bitcoindRPCAuthorization, + fmt.Sprintf("-rpcport=%d", b.rpcPort), + fmt.Sprintf("-port=%d", b.p2pPort), + + // Use ZMQ notifications (blocks + txs) for low-latency chain updates. + "-zmqpubrawblock=" + b.zmqBlockHost, + "-zmqpubrawtx=" + b.zmqTxHost, + "-blockfilterindex=1", + + // Reduce resource usage for test environments. + // + // NOTE: bitcoind performs a file descriptor sanity check on startup. + // Keeping the connection count low reduces the required number of file + // descriptors. + fmt.Sprintf("-maxconnections=%d", bitcoindMaxConnections), + fmt.Sprintf("-maxmempool=%d", bitcoindMaxMempoolMB), + } + + cmdCtx, cmdCancel := context.WithCancel(context.Background()) + b.cmdCancel = cmdCancel + + // #nosec G204 -- b.binary is looked up from PATH and args are controlled. + cmd := exec.CommandContext(cmdCtx, b.binary, args...) + + // #nosec G304 -- b.stdoutPath is created by the test harness. + stdout, err := os.OpenFile( + b.stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + return fmt.Errorf("open bitcoind stdout log: %w", err) + } + + // #nosec G304 -- b.stderrPath is created by the test harness. + stderr, err := os.OpenFile( + b.stderrPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + _ = stdout.Close() + return fmt.Errorf("open bitcoind stderr log: %w", err) + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + + err = cmd.Start() + if err != nil { + _ = stdout.Close() + _ = stderr.Close() + + return fmt.Errorf("start bitcoind: %w", err) + } + + // Keep handles alive for the duration of the process. + b.cmd = cmd + b.stdoutFile = stdout + b.stderrFile = stderr + + // Wait until bitcoind is ready to serve RPC calls. + // + // This readiness check also verifies that bitcoind has synced to the + // pre-mined harness chain height. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + clientCfg := &rpcclient.ConnConfig{ + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + DisableTLS: true, + HTTPPostMode: true, + } + + err = wait.NoError(func() error { + // Construct a short-lived RPC client for readiness probing. + client, err := rpcclient.New(clientCfg, nil) + if err != nil { + return fmt.Errorf("create bitcoind rpc client: %w", err) + } + + defer func() { + client.Shutdown() + client.WaitForShutdown() + }() + + _, err = client.GetBlockChainInfo() + if err != nil { + return fmt.Errorf("get blockchain info: %w", err) + } + + count, err := client.GetBlockCount() + if err != nil { + return fmt.Errorf("get block count: %w", err) + } + + if count < int64(minMatureBlocks) { + return fmt.Errorf("%w (height=%d)", errBitcoindNotSynced, + count) + } + + return nil + }, defaultTestTimeout) + if err != nil { + _ = b.Stop() + + const errFmt = "bitcoind not ready: %w; logs: %s %s" + + return fmt.Errorf(errFmt, err, b.stdoutPath, b.stderrPath) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BitcoindBackend) Stop() error { + if b.cmdCancel != nil { + b.cmdCancel() + b.cmdCancel = nil + } + + if b.cmd != nil && b.cmd.Process != nil { + _ = b.cmd.Process.Kill() + _ = b.cmd.Wait() + } + + // Mark the process handle as stopped so repeated Stop calls are no-ops. + b.cmd = nil + + if b.stdoutFile != nil { + _ = b.stdoutFile.Close() + b.stdoutFile = nil + } + + if b.stderrFile != nil { + _ = b.stderrFile.Close() + b.stderrFile = nil + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BitcoindBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new bitcoind-backed chain.Interface connected to +// this backend. +// +// For each subtest, we create a fresh BitcoindConn and client pair so test +// teardown can fully dispose chain resources without affecting other subtests. +// Startup order matters: +// 1. Construct BitcoindConn. +// 2. Start the connection so RPC + ZMQ subscriptions become active. +// 3. Construct and start the chain client that wallets will use. +// +// Cleanup runs in reverse order to avoid races between client shutdown and +// connection teardown. +func (b *BitcoindBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + // Create a fresh chain connection for each subtest. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + cfg := &chain.BitcoindConfig{ + ChainParams: harnessNetParams, + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + Dialer: nil, + PrunedModeMaxPeers: 0, + // ZMQ endpoints are passed in the same block/tx order used by the + // daemon startup flags above. + ZMQConfig: &chain.ZMQConfig{ + ZMQBlockHost: b.zmqBlockHost, + ZMQTxHost: b.zmqTxHost, + ZMQReadDeadline: bitcoindZMQReadDeadline, + MempoolPollingInterval: bitcoindMempoolPollingInterval, + }, + } + + var ( + conn *chain.BitcoindConn + err error + ) + + err = wait.NoError(func() error { + conn, err = chain.NewBitcoindConn(cfg) + if err != nil { + return fmt.Errorf("create bitcoind conn: %w", err) + } + + return nil + }, defaultTestTimeout) + if err != nil { + return nil, nil, fmt.Errorf("create bitcoind conn: %w", err) + } + + err = conn.Start() + if err != nil { + return nil, nil, fmt.Errorf("start bitcoind conn: %w", err) + } + + client, err := conn.NewBitcoindClient() + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("create bitcoind client: %w", err) + } + + err = client.Start(ctx) + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("start bitcoind client: %w", err) + } + + cleanup := func() { + client.Stop() + conn.Stop() + } + + return client, cleanup, nil +} + +// LogDir returns the directory where bitcoind wrote its logs for this run. +func (b *BitcoindBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BitcoindBackend)(nil) diff --git a/bwtest/logs_finalize.go b/bwtest/logs_finalize.go index 813056e15f..6cb6d455fe 100644 --- a/bwtest/logs_finalize.go +++ b/bwtest/logs_finalize.go @@ -57,11 +57,88 @@ func (h *HarnessTest) finalizeLogs() { h.Logf("failed to flatten btcd backend logs: %v", err) } + case backendBitcoind: + err = flattenBitcoindLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten bitcoind backend logs: %v", err) + } + default: // No backend logs to flatten. } } +// flattenBitcoindLogs concatenates bitcoind logs under srcDir into dstFile. +func flattenBitcoindLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + // Capture process stdout/stderr first, as fatal startup errors might not be + // present in debug.log. + prelude := []string{ + filepath.Join(srcDir, "bitcoind.stderr.log"), + filepath.Join(srcDir, "bitcoind.stdout.log"), + } + + pattern := filepath.Join(srcDir, "*", "debug.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob bitcoind logs: %w", err) + } + + files := make([]string, 0, len(prelude)+len(matches)) + files = append(files, prelude...) + files = append(files, matches...) + + files = filterRegularFiles(files) + if len(files) == 0 { + return nil + } + + // bitcoind rotates debug.log.1, debug.log.2 etc but we don't try too hard + // ordering here. + sort.Strings(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Keep a blank line between concatenated source files to make the + // merged output easier to scan when debugging CI failures. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + _ = os.RemoveAll(srcDir) + + return nil +} + // flattenBtcdLogs concatenates btcd logs under srcDir into dstFile. func flattenBtcdLogs(t *testing.T, srcDir, dstFile string) error { t.Helper() diff --git a/bwtest/rlimit_other.go b/bwtest/rlimit_other.go new file mode 100644 index 0000000000..adc8986216 --- /dev/null +++ b/bwtest/rlimit_other.go @@ -0,0 +1,11 @@ +//go:build !(darwin || linux) + +package bwtest + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// On platforms where this isn't supported, this is a no-op. +func raiseNoFileLimit() error { + return nil +} diff --git a/bwtest/rlimit_unix.go b/bwtest/rlimit_unix.go new file mode 100644 index 0000000000..ed0702ffea --- /dev/null +++ b/bwtest/rlimit_unix.go @@ -0,0 +1,83 @@ +//go:build darwin || linux + +package bwtest + +import ( + "fmt" + "syscall" +) + +const ( + // desiredNoFileLimit is the target soft descriptor limit for test runs that + // launch bitcoind. + desiredNoFileLimit = 4096 + + // assumedInfinityThreshold is the cutoff used to treat RLIMIT_NOFILE values + // as effectively infinite and normalize them to a finite limit. + assumedInfinityThreshold = 1 << 60 +) + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// This is a best-effort helper intended for integration tests that launch +// external processes like bitcoind. Some systems have a low default soft limit, +// which can cause bitcoind to fail during startup. +// +// This helper is still needed because bitcoind validates RLIMIT_NOFILE on +// startup. Normalizing extreme/low values keeps CI environments predictable. +func raiseNoFileLimit() error { + var rlim syscall.Rlimit + + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("get rlimit: %w", err) + } + + newCur, ok := desiredNoFileCur(rlim) + if !ok { + return nil + } + + rlim.Cur = newCur + + err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("set rlimit: %w", err) + } + + return nil +} + +// desiredNoFileCur computes the desired RLIMIT_NOFILE soft value and reports +// whether Setrlimit should be called. +func desiredNoFileCur(rlim syscall.Rlimit) (uint64, bool) { + // Some environments report RLIMIT_NOFILE as effectively infinite. Bitcoind + // fails fast if the value cannot be represented correctly. Normalizing this + // to a finite limit avoids startup failures. + if rlim.Cur >= assumedInfinityThreshold { + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + return newCur, true + } + + // Nothing to do if we're already above our desired limit. + if rlim.Cur >= desiredNoFileLimit { + return 0, false + } + + // Increase the soft limit, but don't exceed the hard limit. + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + if newCur <= rlim.Cur { + return 0, false + } + + return newCur, true +} From 413700a625f126c039f8138b230ac52a836c803b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:51:28 +0800 Subject: [PATCH 108/695] bwtest: add neutrino backend --- bwtest/backend.go | 26 ++-------- bwtest/neutrino.go | 119 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 bwtest/neutrino.go diff --git a/bwtest/backend.go b/bwtest/backend.go index 2428d676a0..bc6667f5a4 100644 --- a/bwtest/backend.go +++ b/bwtest/backend.go @@ -58,32 +58,12 @@ func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { return NewBitcoindBackend(t, logDir) case backendNeutrino: - t.Fatalf("chain backend %q is not implemented yet", backendType) - return nil + // Neutrino is an in-process backend and does not require a backend log + // directory. + return NewNeutrinoBackend(t, logDir) default: t.Fatalf("unknown chain backend %q", backendType) return nil } } - -// validateBackendType validates the chain backend identifier provided by test -// flags before backend construction starts. -func validateBackendType(t *testing.T, backendType string) { - t.Helper() - - switch backendType { - case backendBtcd: - return - - case backendBitcoind: - return - - case backendNeutrino: - t.Fatalf("chain backend %q is not implemented yet", backendType) - return - - default: - t.Fatalf("unknown chain backend %q", backendType) - } -} diff --git a/bwtest/neutrino.go b/bwtest/neutrino.go new file mode 100644 index 0000000000..5a81e24bdc --- /dev/null +++ b/bwtest/neutrino.go @@ -0,0 +1,119 @@ +package bwtest + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb walletdb driver. + "github.com/lightninglabs/neutrino" +) + +const neutrinoDBTimeout = 5 * time.Second + +// NeutrinoBackend is a ChainBackend that uses an in-process neutrino chain +// service connected to the shared miner. +type NeutrinoBackend struct { + minerAddr string +} + +// NewNeutrinoBackend creates a new NeutrinoBackend. +// +// Neutrino is an in-process backend and does not write process logs into the +// passed logDir. +func NewNeutrinoBackend(t *testing.T, _ string) *NeutrinoBackend { + t.Helper() + + return &NeutrinoBackend{} +} + +// Name returns the identifier of the backend. +func (n *NeutrinoBackend) Name() string { + return backendNeutrino +} + +// Start is a no-op for neutrino. +func (n *NeutrinoBackend) Start() error { + return nil +} + +// Stop is a no-op for neutrino. +func (n *NeutrinoBackend) Stop() error { + return nil +} + +// ConnectMiner records the miner address for later use. +func (n *NeutrinoBackend) ConnectMiner(minerAddr string) error { + n.minerAddr = minerAddr + return nil +} + +// NewChainClient creates a new neutrino-backed chain.Interface connected to +// the shared miner. +func (n *NeutrinoBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + if n.minerAddr == "" { + return nil, nil, fmt.Errorf("neutrino: %w", errMissingMinerAddr) + } + + dataDir, err := os.MkdirTemp("", "btcwallet-neutrino-") + if err != nil { + return nil, nil, fmt.Errorf("create neutrino temp dir: %w", err) + } + + spvdb, err := walletdb.Create( + "bdb", filepath.Join(dataDir, "neutrino.db"), true, + neutrinoDBTimeout, false, + ) + if err != nil { + _ = os.RemoveAll(dataDir) + return nil, nil, fmt.Errorf("create neutrino db: %w", err) + } + + chainService, err := neutrino.NewChainService(neutrino.Config{ + DataDir: dataDir, + Database: spvdb, + ChainParams: *harnessNetParams, + ConnectPeers: []string{n.minerAddr}, + }) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("create neutrino chain service: %w", err) + } + + client := chain.NewNeutrinoClient(harnessNetParams, chainService) + + err = client.Start(ctx) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("start neutrino client: %w", err) + } + + cleanup := func() { + client.Stop() + client.WaitForShutdown() + + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + } + + return client, cleanup, nil +} + +// LogDir returns an empty string because neutrino has no backend daemon. +func (n *NeutrinoBackend) LogDir() string { + // Neutrino runs in-process, so there is no backend daemon log directory. + return "" +} + +var _ ChainBackend = (*NeutrinoBackend)(nil) From cc5eaa2e94f52252a2396e45ce546e8152296a94 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:14:42 +0800 Subject: [PATCH 109/695] bwtest: add wallet helper methods --- bwtest/harness_wallet.go | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 bwtest/harness_wallet.go diff --git a/bwtest/harness_wallet.go b/bwtest/harness_wallet.go new file mode 100644 index 0000000000..1be8b49194 --- /dev/null +++ b/bwtest/harness_wallet.go @@ -0,0 +1,120 @@ +package bwtest + +import ( + "context" + "strings" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +const ( + // defaultPubPass is the standard public passphrase used by test wallets. + defaultPubPass = "public" + + // defaultPrivPass is the standard private passphrase used by test wallets. + defaultPrivPass = "private" + + // defaultWalletRecoveryWindow keeps enough look-ahead addresses for test + // cases that derive multiple addresses while scanning historical blocks. + defaultWalletRecoveryWindow = 20 + + // defaultWalletSyncRetryInterval controls how often wallet sync retries + // when the chain backend is temporarily unavailable during startup. + defaultWalletSyncRetryInterval = 500 * time.Millisecond +) + +// CreateEmptyWallet creates, starts, and registers a new wallet instance. +// +// This is intended for non-manager integration tests that want a ready-to-use +// wallet without repeating boilerplate. +func (h *HarnessTest) CreateEmptyWallet() *wallet.Wallet { + h.Helper() + + name := "itest-" + strings.ReplaceAll(h.Name(), "/", "_") + + cfg := wallet.Config{ + // Use the subtest-scoped DB and chain client prepared by the harness. + DB: h.WalletDB, + Chain: h.ChainClient, + + // Keep network and startup behavior deterministic across tests. + ChainParams: h.NetParams(), + RecoveryWindow: defaultWalletRecoveryWindow, + WalletSyncRetryInterval: defaultWalletSyncRetryInterval, + + // Use a unique wallet name per test to avoid collisions in logs. + Name: name, + PubPassphrase: []byte(defaultPubPass), + } + + params := wallet.CreateWalletParams{ + // Generate a fresh seed for each test wallet. + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte(defaultPubPass), + PrivatePassphrase: []byte(defaultPrivPass), + + // Use an old birthday to ensure the wallet can discover historical + // blocks when used in tests that pre-mine chain state. + Birthday: time.Now().Add(-1 * time.Hour), + } + + manager := wallet.NewManager() + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + + h.Cleanup(func() { + // We use a background context here because the test context might be + // canceled by the time cleanup runs. + _ = w.Stop(context.Background()) + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + return w +} + +// CreateFundedWallet creates an empty wallet and funds it with 10 BTC. +// +// This is intended for future integration tests that need spendable funds. +func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet { + h.Helper() + + w := h.CreateEmptyWallet() + + err := w.Unlock(h.Context(), wallet.UnlockRequest{ + Passphrase: []byte(defaultPrivPass), + }) + require.NoError(h, err, "failed to unlock wallet") + + addr, err := w.NewAddress( + h.Context(), waddrmgr.DefaultAccountName, + waddrmgr.WitnessPubKey, false, + ) + require.NoError(h, err, "failed to create address") + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(h, err, "failed to create pkscript") + + const tenBTC = 10 * btcutil.SatoshiPerBitcoin + + output := &wire.TxOut{Value: int64(tenBTC), PkScript: pkScript} + + // Use a minimal fee rate for regtest. + h.SendOutput(output, btcutil.Amount(1)) + + // Confirm and wait for sync. + h.MineBlocks(1) + h.AssertWalletSynced(w) + + return w +} From b9c0b078c0ec8c33259403038a75beb80d566bc5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:14:59 +0800 Subject: [PATCH 110/695] docs: add bwtest and itest readmes --- bwtest/README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ itest/README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 bwtest/README.md create mode 100644 itest/README.md diff --git a/bwtest/README.md b/bwtest/README.md new file mode 100644 index 0000000000..a4fb7e29a7 --- /dev/null +++ b/bwtest/README.md @@ -0,0 +1,60 @@ +# bwtest + +`bwtest` contains the integration test harness used by `itest`. + +## Overview + +The harness provides: + +- A shared miner (btcd) that produces blocks for all test cases. +- A configurable chain backend under test (`btcd`, `bitcoind`, `neutrino`). +- Per-subtest resources: + - A fresh `chain.Interface` instance. + - A fresh wallet database instance. +- Cleanup that keeps tests isolated: + - Stops wallets created by the test. + - Requires the miner mempool to be empty on success. + +## Logs + +Each test run creates a per-run log directory under `itest/test-logs`. + +- Backend logs are flattened into `miner.log` and `chain_backend.log`. +- Wallet logs are written per test case as `wallet-.log`. + +## Backends + +Chain backends are implemented in separate files: + +- `bwtest/btcd.go` +- `bwtest/bitcoind.go` +- `bwtest/neutrino.go` + +The `bitcoind` backend uses ZMQ for block/tx notifications. + +## Wallet Helpers + +`bwtest` includes convenience helpers for tests that do not want to directly +exercise the wallet manager: + +- `(*HarnessTest).CreateEmptyWallet` +- `(*HarnessTest).CreateFundedWallet` + +Example usage: + +```go +func testFoo(t *bwtest.HarnessTest) { + t.CreateEmptyWallet() + + // Now add tests that need a started wallet instance. +} + +func testBar(t *bwtest.HarnessTest) { + t.CreateFundedWallet() + + // Now add tests that need a wallet with spendable funds. +} +``` + +Manager-focused tests should continue to create wallets through the manager API +directly. diff --git a/itest/README.md b/itest/README.md new file mode 100644 index 0000000000..1c1e9e4adc --- /dev/null +++ b/itest/README.md @@ -0,0 +1,57 @@ +# itest + +`itest` contains end-to-end integration tests for `btcwallet` using the harness +in `bwtest`. + +## Running Tests + +Common invocations: + +```bash +make itest + +# Select a chain backend. +make itest chain=btcd +make itest chain=bitcoind + +# Select a wallet database backend. +make itest db=kvdb + +# Filter cases by regex. +make itest icase=manager +``` + +The `chain` and `db` variables are forwarded into the test binary as flags. + +## Test Case Naming + +Integration test case names must follow: + +``` +component action +``` + +For example: + +``` +manager create wallet +``` + +This is validated by `itest/main_test.go`. + +## Logs + +Each test run creates a per-run log directory under: + +`itest/test-logs/log---YYYYMMDD-HHMMSS/` + +The harness flattens backend logs into: + +- `miner.log` +- `chain_backend.log` + +Wallet logs are created per test case: + +- `wallet-.log` + +The log directory path is printed when it is created. From 80eca0b76f5c5ea811c40b3a60bd148c39532660 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:15:14 +0800 Subject: [PATCH 111/695] CI: add itest jobs and log artifacts --- .github/workflows/main.yml | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 45b53ae614..3731ae6724 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,8 @@ env: BITCOIND_VERSION: '22.0' BITCOIND_IMAGE: 'lightninglabs/bitcoin-core' + BTCD_VERSION_LATEST: v0.25.0 + jobs: ######################## # Format, compileation and lint check @@ -213,6 +215,164 @@ jobs: format: golang parallel: true + ######################## + # Run bwtest integration tests against each supported chain backend. + # + # These jobs currently use kvdb only. Database expansion is planned via a + # separate matrix in a future change. + ######################## + itest-btcd: + name: itest btcd + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # The btcd backend job runs btcd for both the shared miner and the chain + # backend under test. + - name: run itest (btcd, kvdb) + run: make itest chain=btcd db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-btcd + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the neutrino backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}`. + # - Use btcd as the shared miner for the suite. + # - Run `make itest chain=neutrino db=kvdb` so wallets use the in-process + # neutrino backend while blocks/peers come from btcd. + ######################## + itest-neutrino: + name: itest neutrino + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # Neutrino runs in-process, but it still relies on the shared btcd miner + # for chain data and peer connectivity. + - name: run itest (neutrino, kvdb) + run: make itest chain=neutrino db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-neutrino + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the bitcoind backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}` as the shared miner. + # - Run a matrix over bitcoind versions `30` (latest) and `28` (older). + # - Extract each matrix binary from the docker image and run + # `make itest chain=bitcoind db=kvdb`. + ######################## + itest-bitcoind: + name: itest bitcoind (v${{ matrix.bitcoind_version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bitcoind_version: ['30', '28'] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # For bitcoind backend tests, btcd remains the shared miner while the + # matrix covers both bitcoind v30 (latest) and v28 (older). + - name: extract bitcoind from docker image (${{ matrix.bitcoind_version }}) + run: |- + docker pull ${{ env.BITCOIND_IMAGE }}:${{ matrix.bitcoind_version }} + CONTAINER_ID=$(docker create ${{ env.BITCOIND_IMAGE }}:${{ matrix.bitcoind_version }}) + + # Different upstream image tags use slightly different install paths. + # Try common variants in order until one succeeds. + for BIN_PATH in \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}/bin/bitcoind" \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}.0/bin/bitcoind" \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}.0.0/bin/bitcoind"; do + if sudo docker cp $CONTAINER_ID:${BIN_PATH} /usr/local/bin/bitcoind; then + break + fi + done + + docker rm $CONTAINER_ID + bitcoind --version + + - name: run itest (bitcoind, kvdb) + run: make itest chain=bitcoind db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-bitcoind-${{ matrix.bitcoind_version }} + path: itest/test-logs + retention-days: 5 + ######################## # Complete parallel coverage uploads ######################## From e8e549e3c78eed6f447a376ed6dcaef21c48d742 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 12 Feb 2026 23:13:40 +0800 Subject: [PATCH 112/695] bwtest: use `waddrmgr.FastScryptOptions` --- bwtest/README.md | 7 +++++++ bwtest/harness_wallet.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/bwtest/README.md b/bwtest/README.md index a4fb7e29a7..880fb66083 100644 --- a/bwtest/README.md +++ b/bwtest/README.md @@ -58,3 +58,10 @@ func testBar(t *bwtest.HarnessTest) { Manager-focused tests should continue to create wallets through the manager API directly. + +## Fast Scrypt + +`bwtest` sets `waddrmgr.DefaultScryptOptions` to `waddrmgr.FastScryptOptions` via +an `init()` function. Any package that imports `bwtest` (including `itest`) +automatically benefits from faster key derivation, avoiding CPU exhaustion and +timeouts — especially when running with `-race`. diff --git a/bwtest/harness_wallet.go b/bwtest/harness_wallet.go index 1be8b49194..472404ab60 100644 --- a/bwtest/harness_wallet.go +++ b/bwtest/harness_wallet.go @@ -118,3 +118,9 @@ func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet { return w } + +func init() { + // Use fast scrypt options for tests to avoid CPU exhaustion and + // timeouts, especially when running with -race. + waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions +} From 647837f41f3e8818ee0ffddb588136115bc52837 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 12 Feb 2026 23:30:58 +0800 Subject: [PATCH 113/695] CI: remove unnecessary caching --- .github/workflows/main.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3731ae6724..6c70739ec3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -134,17 +134,6 @@ jobs: - name: Clean up runner space uses: ./.github/actions/cleanup-space - - name: go cache - uses: actions/cache@v4 - with: - path: /home/runner/work/go - key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- - btcwallet-${{ runner.os }}-go- - - name: setup go ${{ env.GO_VERSION }} uses: actions/setup-go@v5 with: @@ -167,7 +156,7 @@ jobs: # run integration tests (SQLite + Postgres) ######################## itest-db: - name: integration tests (${{ matrix.db }}, ${{ matrix.race && 'race' || 'cover' }}) + name: ${{ matrix.db }} itest (${{ matrix.race && 'race' || 'cover' }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -181,17 +170,6 @@ jobs: - name: Clean up runner space uses: ./.github/actions/cleanup-space - - name: go cache - uses: actions/cache@v4 - with: - path: /home/runner/work/go - key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- - btcwallet-${{ runner.os }}-go- - - name: setup go ${{ env.GO_VERSION }} uses: actions/setup-go@v5 with: From 9e29c8ecb979ecf46d50cc77adae04cd9e887d21 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:55:40 +0800 Subject: [PATCH 114/695] wallet: introduce db.Store interface This is a transitional abstraction while the wallet remains monolithic. --- wallet/internal/db/interface.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 0605ac9d22..32899b851f 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -74,6 +74,28 @@ var ( ErrMaxAddressIndexReached = errors.New("max address index reached") ) +// Store defines the set of database operations used by the wallet. +// +// NOTE: Ideally each wallet component/manager should depend on a small, +// purpose-built interface (for example, the UtxoManager should only depend on +// UTXOStore). However, the wallet is still a monolithic struct and its managers +// are currently only separated by files, all implemented as methods on Wallet. +// Until we break the wallet into independent components, we use this monolithic +// Store abstraction as a transitional step. +// +// For this PR, Store only includes UTXOStore. Over time it is expected to grow +// to include WalletStore, AccountStore, AddressStore, and TxStore as those +// callers migrate to the new internal db interfaces. +// +// TODO(yy): Break down wallet managers into independent components. +// +// TODO(yy): Remove the linter ignore once Store grows beyond UTXOStore. +// +//nolint:iface // Transitional alias until Store grows beyond UTXOStore. +type Store interface { + UTXOStore +} + // WalletStore defines the methods for wallet-level operations. type WalletStore interface { // CreateWallet creates a new wallet in the database with the provided @@ -251,6 +273,8 @@ type TxStore interface { } // UTXOStore defines the database actions for managing the UTXO set. +// +//nolint:iface // Store is a transitional wrapper over UTXOStore. type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details From 418c8efd8573434cde37143e7e78b7ce7ad9be8d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:55:52 +0800 Subject: [PATCH 115/695] wallet: add kvdb Store skeleton Introduce kvdb-backed Store implementation scaffolding. --- wallet/internal/db/kvdb/driver.go | 28 ++++++++ wallet/internal/db/kvdb/utxo.go | 106 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 wallet/internal/db/kvdb/driver.go create mode 100644 wallet/internal/db/kvdb/utxo.go diff --git a/wallet/internal/db/kvdb/driver.go b/wallet/internal/db/kvdb/driver.go new file mode 100644 index 0000000000..0eb5b35eaf --- /dev/null +++ b/wallet/internal/db/kvdb/driver.go @@ -0,0 +1,28 @@ +package kvdb + +import ( + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +// Store is the kvdb (walletdb) implementation of the db.Store interface. +// +// NOTE: This is a partial implementation that will be expanded as the wallet +// UTXO manager migrates to the new db interfaces. +type Store struct { + db walletdb.DB + txStore wtxmgr.TxStore +} + +// A compile-time assertion to ensure that Store implements the db.Store +// interface. +var _ db.Store = (*Store)(nil) + +// NewStore creates a new kvdb-backed UTXO store. +func NewStore(dbConn walletdb.DB, txStore wtxmgr.TxStore) *Store { + return &Store{ + db: dbConn, + txStore: txStore, + } +} diff --git a/wallet/internal/db/kvdb/utxo.go b/wallet/internal/db/kvdb/utxo.go new file mode 100644 index 0000000000..2f53d19f83 --- /dev/null +++ b/wallet/internal/db/kvdb/utxo.go @@ -0,0 +1,106 @@ +// Package kvdb provides a walletdb (kvdb) backed implementation of the +// wallet/internal/db UTXO store interface. +package kvdb + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +var ( + // errNotImplemented is returned for unimplemented kvdb store methods. + errNotImplemented = errors.New("not implemented") + + // errMissingTxmgrNamespace is returned when the `wtxmgr` namespace bucket + // cannot be found in a walletdb transaction. + errMissingTxmgrNamespace = errors.New("missing wtxmgr namespace") + + // wtxmgrNamespaceKey is the walletdb top-level bucket key used by the + // transaction manager. + // + // NOTE: This must match the namespace used by the wallet package. + wtxmgrNamespaceKey = []byte("wtxmgr") +) + +func notImplemented(_ context.Context, method string) error { + return fmt.Errorf("kvdb.Store.%s: %w", method, errNotImplemented) +} + +// GetUtxo is not yet implemented for kvdb. +func (s *Store) GetUtxo(ctx context.Context, + _ db.GetUtxoQuery) (*db.UtxoInfo, error) { + + return nil, notImplemented(ctx, "GetUtxo") +} + +// ListUTXOs is not yet implemented for kvdb. +func (s *Store) ListUTXOs(ctx context.Context, + _ db.ListUtxosQuery) ([]db.UtxoInfo, error) { + + return nil, notImplemented(ctx, "ListUTXOs") +} + +// LeaseOutput is not yet implemented for kvdb. +func (s *Store) LeaseOutput(ctx context.Context, + _ db.LeaseOutputParams) (*db.LeasedOutput, error) { + + return nil, notImplemented(ctx, "LeaseOutput") +} + +// ReleaseOutput releases a previously leased output. +// +// How it works: +// The method executes a single walletdb update transaction that deletes the +// lock record associated with the specified outpoint. +// +// Database Actions: +// - Performs exactly one write transaction (walletdb.Update). +// - Writes to the `wtxmgr` namespace. +// +// NOTE: The legacy kvdb backend only supports a single wallet instance, so the +// WalletID field is ignored. +func (s *Store) ReleaseOutput(_ context.Context, + params db.ReleaseOutputParams) error { + + lockID := wtxmgr.LockID(params.ID) + op := params.OutPoint + + err := walletdb.Update(s.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + if ns == nil { + return errMissingTxmgrNamespace + } + + err := s.txStore.UnlockOutput(ns, lockID, op) + if err != nil { + return fmt.Errorf("unlock output: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("kvdb.Store.ReleaseOutput: %w", err) + } + + return nil +} + +// ListLeasedOutputs is not yet implemented for kvdb. +func (s *Store) ListLeasedOutputs(ctx context.Context, + _ uint32) ([]db.LeasedOutput, error) { + + return nil, notImplemented(ctx, "ListLeasedOutputs") +} + +// Balance is not yet implemented for kvdb. +func (s *Store) Balance(ctx context.Context, + _ db.BalanceParams) (btcutil.Amount, error) { + + return 0, notImplemented(ctx, "Balance") +} From 2268f85772046a05c41708ca6cbbaa62703fcaad Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:56:17 +0800 Subject: [PATCH 116/695] wallet: add Store field to Wallet The wallet now carries a monolithic db.Store for manager access and initialize the wallet's Store using the kvdb backend. --- wallet/deprecated.go | 2 ++ wallet/manager.go | 2 ++ wallet/wallet.go | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 99e13c1aea..e638fb2c15 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -28,6 +28,7 @@ import ( "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/internal/prompt" "github.com/btcsuite/btcwallet/waddrmgr" + kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" @@ -6715,6 +6716,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, w := &Wallet{ addrStore: addrMgr, + store: kvdb.NewStore(db, txMgr), txStore: txMgr, walletDeprecated: deprecated, } diff --git a/wallet/manager.go b/wallet/manager.go index 7264e4ddfd..99b42e98a8 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" + kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" ) var ( @@ -294,6 +295,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { w := &Wallet{ cfg: cfg, addrStore: addrMgr, + store: kvdb.NewStore(cfg.DB, txMgr), txStore: txMgr, requestChan: make(chan any), lifetimeCtx: lifetimeCtx, diff --git a/wallet/wallet.go b/wallet/wallet.go index ecfa297091..03f4b2e168 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -24,6 +24,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -340,6 +341,11 @@ type Wallet struct { // querying the wallet's transaction history and unspent outputs. txStore wtxmgr.TxStore + // store provides access to database operations used by wallet managers. + // + // TODO(yy): Migrate UTXO-related callers behind db.UTXOStore. + store db.Store + // NtfnServer handles the delivery of wallet-related events (e.g., new // transactions, block connections) to connected clients. // From 981802a7f8c9aad244452b0744a792150589338c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:56:06 +0800 Subject: [PATCH 117/695] wallet: add mockStore for db.Store Use a single Store mock in tests to match the monolithic wallet store. --- wallet/common_test.go | 5 +++ wallet/mock_test.go | 88 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index 07d528ad46..578048fc1d 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -94,6 +94,7 @@ func setupTestDB(t *testing.T) (walletdb.DB, func()) { // mockWalletDeps holds the mocked dependencies for the Wallet. type mockWalletDeps struct { addrStore *mockAddrStore + store *mockStore txStore *mockTxStore syncer *mockChainSyncer chain *mockChain @@ -113,6 +114,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Cleanup(cleanup) mockAddrStore := &mockAddrStore{} + mockStore := &mockStore{} mockTxStore := &mockTxStore{} mockSyncer := &mockChainSyncer{} mockChain := &mockChain{} @@ -125,6 +127,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { w := &Wallet{ addrStore: mockAddrStore, + store: mockStore, txStore: mockTxStore, sync: mockSyncer, state: newWalletState(mockSyncer), @@ -147,6 +150,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { deps := &mockWalletDeps{ addrStore: mockAddrStore, + store: mockStore, txStore: mockTxStore, syncer: mockSyncer, chain: mockChain, @@ -158,6 +162,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Cleanup(func() { mockAddrStore.AssertExpectations(t) + mockStore.AssertExpectations(t) mockTxStore.AssertExpectations(t) mockSyncer.AssertExpectations(t) mockChain.AssertExpectations(t) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 7643c1e32d..ab31462cfe 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -2,9 +2,8 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. -// This file contains a mock implementation of the wtxmgr.TxStore interface. -// It is used in various tests to isolate wallet logic from the underlying -// database. +// This file contains mock implementations of wallet dependencies used in +// tests to isolate wallet logic from underlying storage backends. package wallet @@ -23,6 +22,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" @@ -31,6 +31,88 @@ import ( "github.com/stretchr/testify/mock" ) +// mockStore is a mock implementation of the db.Store interface. +// +// It is used to unit test wallet UTXO manager public methods without +// exercising a real database backend. +type mockStore struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockStore implements the db.Store +// interface. +var _ db.Store = (*mockStore)(nil) + +// GetUtxo implements the db.UTXOStore interface. +func (m *mockStore) GetUtxo(ctx context.Context, + query db.GetUtxoQuery) (*db.UtxoInfo, error) { + + args := m.Called(ctx, query) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*db.UtxoInfo), args.Error(1) +} + +// ListUTXOs implements the db.UTXOStore interface. +func (m *mockStore) ListUTXOs(ctx context.Context, + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { + + args := m.Called(ctx, query) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]db.UtxoInfo), args.Error(1) +} + +// LeaseOutput implements the db.UTXOStore interface. +func (m *mockStore) LeaseOutput(ctx context.Context, + params db.LeaseOutputParams) (*db.LeasedOutput, error) { + + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*db.LeasedOutput), args.Error(1) +} + +// ReleaseOutput implements the db.UTXOStore interface. +func (m *mockStore) ReleaseOutput(ctx context.Context, + params db.ReleaseOutputParams) error { + + args := m.Called(ctx, params) + return args.Error(0) +} + +// ListLeasedOutputs implements the db.UTXOStore interface. +func (m *mockStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]db.LeasedOutput, error) { + + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]db.LeasedOutput), args.Error(1) +} + +// Balance implements the db.UTXOStore interface. +func (m *mockStore) Balance(ctx context.Context, + params db.BalanceParams) (btcutil.Amount, error) { + + args := m.Called(ctx, params) + + amount, ok := args.Get(0).(btcutil.Amount) + if !ok { + return 0, args.Error(1) + } + + return amount, args.Error(1) +} + // mockTxStore is a mock implementation of the wtxmgr.TxStore interface. type mockTxStore struct { mock.Mock From 93a6e2b422626fcbb9b6760cc4d6879870164b88 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:57:55 +0800 Subject: [PATCH 118/695] wallet: migrate ReleaseOutput behind Store Route ReleaseOutput through the wallet Store abstraction. --- wallet/utxo_manager.go | 51 ++++++++++--------------------------- wallet/utxo_manager_test.go | 11 +++++--- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 9fea82e751..3b7ec36cc0 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -451,41 +452,10 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, } // ReleaseOutput unlocks a previously leased output, making it available for -// use. +// coin selection again. // -// This method allows a caller to manually release a lock on a UTXO before its -// expiration time. This is useful when a transaction-building process is -// aborted and the reserved inputs need to be returned to the pool of available -// UTXOs. -// -// How it works: -// The method delegates the unlocking operation to the underlying transaction -// store (`wtxmgr`), which removes the lock record for the specified outpoint. -// -// Logical Steps: -// 1. Initiate a read-write database transaction. -// 2. Call the `wtxmgr.UnlockOutput` method with the provided `LockID` and -// outpoint. -// 3. The `wtxmgr` verifies that the output is indeed locked by the same -// `LockID` before removing the lock. -// -// Database Actions: -// - This method performs a single read-write database transaction -// (`walletdb.Update`). -// - It deletes from the `wtxmgr` namespace to remove the output lock. -// -// Time Complexity: -// - The complexity is O(1) as it involves a direct lookup and delete in the -// database. -// -// TODO(yy): The current `wtxmgr.UnlockOutput` implementation does not validate -// that the `LockID` matches the one that currently holds the lock. This could -// allow any caller to unlock an output, which could be a potential security -// risk in a multi-user environment. The implementation should be improved to -// perform this check. -// -// NOTE: This is part of the UtxoManager interface implementation. -func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, +// The lock is released by delegating to the wallet's db.Store implementation. +func (w *Wallet) ReleaseOutput(ctx context.Context, id wtxmgr.LockID, op wire.OutPoint) error { err := w.state.validateStarted() @@ -493,10 +463,15 @@ func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, return err } - return walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { - txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.txStore.UnlockOutput(txmgrNs, id, op) - }) + params := db.ReleaseOutputParams{ + // TODO(yy): When multi-wallet support lands, plumb wallet ID into db + // calls instead of hard-coding 0. + WalletID: 0, + ID: [32]byte(id), + OutPoint: op, + } + + return w.store.ReleaseOutput(ctx, params) } // ListLeasedOutputs returns a list of all currently leased outputs. diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index d1a23cd17a..31f5b7da19 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -319,10 +320,12 @@ func TestReleaseOutput(t *testing.T) { Index: 0, } - // Mock the UnlockOutput method to return nil. - mocks.txStore.On("UnlockOutput", - mock.Anything, mock.Anything, utxo, - ).Return(nil) + // Mock the UTXOStore ReleaseOutput method to return nil. + mocks.store.On("ReleaseOutput", mock.Anything, db.ReleaseOutputParams{ + WalletID: 0, + ID: [32]byte{1}, + OutPoint: utxo, + }).Return(nil) // Now, try to release the output. leaseID := wtxmgr.LockID{1} From 067f92f78fb68834690777a8cf5a0385973a6efb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:58:08 +0800 Subject: [PATCH 119/695] wallet: add kvdb Store tests Add unit tests for the kvdb-backed Store implementation. --- wallet/internal/db/kvdb/utils_test.go | 65 +++++++++++++++ wallet/internal/db/kvdb/utxo_test.go | 114 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 wallet/internal/db/kvdb/utils_test.go create mode 100644 wallet/internal/db/kvdb/utxo_test.go diff --git a/wallet/internal/db/kvdb/utils_test.go b/wallet/internal/db/kvdb/utils_test.go new file mode 100644 index 0000000000..3be25c9cb1 --- /dev/null +++ b/wallet/internal/db/kvdb/utils_test.go @@ -0,0 +1,65 @@ +package kvdb + +import ( + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +const defaultDBTimeout = 10 * time.Second + +// newTestDB creates a temporary bdb walletdb for kvdb store tests. +// +// It returns the opened database and a cleanup function that must be called +// after the test completes. +func newTestDB(t *testing.T) (walletdb.DB, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + dbConn, err := walletdb.Create( + "bdb", dbPath, true, defaultDBTimeout, false, + ) + require.NoError(t, err) + + cleanup := func() { + _ = dbConn.Close() + } + + return dbConn, cleanup +} + +// newTxStore initializes and opens a wtxmgr store in the test database. +// +// NOTE: The kvdb Store under test expects the walletdb top-level bucket key +// `wtxmgrNamespaceKey` to exist and contain a valid wtxmgr store. +func newTxStore(t *testing.T, dbConn walletdb.DB) *wtxmgr.Store { + t.Helper() + + var txStore *wtxmgr.Store + + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + ns, err := tx.CreateTopLevelBucket(wtxmgrNamespaceKey) + if err != nil { + return err + } + + err = wtxmgr.Create(ns) + if err != nil { + return err + } + + txStore, err = wtxmgr.Open(ns, &chaincfg.RegressionNetParams) + + return err + }) + require.NoError(t, err) + + return txStore +} diff --git a/wallet/internal/db/kvdb/utxo_test.go b/wallet/internal/db/kvdb/utxo_test.go new file mode 100644 index 0000000000..01d05afd0c --- /dev/null +++ b/wallet/internal/db/kvdb/utxo_test.go @@ -0,0 +1,114 @@ +package kvdb + +import ( + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// TestReleaseOutputSuccess verifies that kvdb.Store.ReleaseOutput removes an +// existing output lease from the underlying wtxmgr store. +func TestReleaseOutputSuccess(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + txStore := newTxStore(t, dbConn) + store := NewStore(dbConn, txStore) + + lockID := wtxmgr.LockID{1} + op := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + // Arrange: Create a lease so there is something to release. + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + // Create a mock transaction to satisfy the "known output" check in + // wtxmgr. + txMsg := &wire.MsgTx{ + Version: 1, + TxOut: []*wire.TxOut{{ + Value: 1000, + PkScript: []byte{0x00}, // OP_0 + }}, + } + + rec, err := wtxmgr.NewTxRecordFromMsgTx(txMsg, time.Now()) + if err != nil { + return fmt.Errorf("create tx record: %w", err) + } + + // Insert the transaction as mined. + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 1}, + Time: time.Now(), + } + + err = txStore.InsertTx(ns, rec, block) + if err != nil { + return fmt.Errorf("insert tx: %w", err) + } + + // Add the output as a credit so wtxmgr knows about it. + err = txStore.AddCredit(ns, rec, block, 0, false) + if err != nil { + return fmt.Errorf("add credit: %w", err) + } + + // Use the inserted transaction's hash for the outpoint. + op.Hash = rec.Hash + + _, err = txStore.LockOutput(ns, lockID, op, time.Hour) + + return err + }) + require.NoError(t, err) + + // Act: Release the lease through the kvdb store implementation. + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: 1, + ID: [32]byte(lockID), + OutPoint: op, + }) + require.NoError(t, err) + + // Assert: The lock set is now empty. + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + locked, err := txStore.ListLockedOutputs(ns) + require.NoError(t, err) + require.Empty(t, locked) + + return nil + }) + require.NoError(t, err) +} + +// TestReleaseOutputMissingNamespace verifies a helpful error is returned when +// the `wtxmgr` namespace bucket is not present. +func TestReleaseOutputMissingNamespace(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + store := NewStore(dbConn, nil) + + err := store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: 0, + ID: [32]byte{1}, + OutPoint: wire.OutPoint{Hash: [32]byte{1}, Index: 0}, + }) + require.Error(t, err) + require.ErrorIs(t, err, errMissingTxmgrNamespace) +} From 6d3fcfe5b01130fc7e9c618159c5454c209a8b3f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:59:41 +0800 Subject: [PATCH 120/695] wallet: require Store initialized in benchmarks Benchmarks should not lazily initialize the wallet Store. --- wallet/benchmark_helpers_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 2869484c35..664872d43c 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -311,6 +311,8 @@ func setupBenchmarkWallet(tb testing.TB, w.sync = newSyncer(w.cfg, w.addrStore, w.txStore, w) } + require.NotNil(tb, w.store) + // Initialize controller channels and timer. if w.requestChan == nil { w.requestChan = make(chan any) From 0d452d0d709cb2c62e6d9d41bbf18c404e37b7a7 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 16:36:49 -0300 Subject: [PATCH 121/695] docs: add XChaCha20-Poly1305 Encryption ADR --- .../adr/0007-xchacha20-poly1305-encryption.md | 99 +++++++++++++++++++ docs/developer/adr/README.md | 2 + 2 files changed, 101 insertions(+) create mode 100644 docs/developer/adr/0007-xchacha20-poly1305-encryption.md diff --git a/docs/developer/adr/0007-xchacha20-poly1305-encryption.md b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md new file mode 100644 index 0000000000..3e64dcf051 --- /dev/null +++ b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md @@ -0,0 +1,99 @@ +# ADR 0007: XChaCha20-Poly1305 Encryption + +## 1. Context + +As part of the broader effort to simplify wallet encryption to a single +passphrase model, we are revisiting the cryptographic primitives used to +protect sensitive data. Today, `btcwallet` relies on NaCl secretbox, based on +XSalsa20-Poly1305, to encrypt private key material. + +While XSalsa20-Poly1305 remains secure, XChaCha20-Poly1305 is now the +preferred successor in modern systems. It preserves the large nonce model +while offering better library support and alignment with current AEAD +standards. + +Key motivations for this change include: + +* Broad adoption of XChaCha20-Poly1305 in modern cryptographic systems +* Strong performance on systems without AES-NI, including mobile and embedded + environments +* Simpler and more uniform AEAD construction, reducing implementation risk +* Easier auditing due to reduced complexity +* Production-proven usage in the Lightning Network's encrypted transport + protocol ([BOLT + 8](https://github.com/lightning/bolts/blob/master/08-transport.md)), + demonstrating its suitability for Bitcoin-related projects + +Like XSalsa20, XChaCha20-Poly1305 uses a 192-bit nonce, which allows random +nonce generation without practical collision risk and matches our existing +nonce strategy. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[XChaCha20-Poly1305] + D --> E[Encrypted Private Data] +``` + +## 2. Decision + +We will migrate the encryption primitive from NaCl secretbox, based on +XSalsa20-Poly1305, to **XChaCha20-Poly1305**. + +### Key aspects + +1. **Encryption primitive** + Use XChaCha20-Poly1305 from `golang.org/x/crypto/chacha20poly1305`, + specifically the XChaCha variant with 192-bit nonces. + +2. **Key hierarchy** + Preserve the existing structure: scrypt-based KDF, master key derivation, + and per-use encryption keys. + +3. **Data separation** + Continue storing public data in plaintext while encrypting private or + sensitive material only. + +4. **Migration path** + Encrypted data will be re-encrypted during the SQL migration process. + +## 3. Consequences + +### Pros + +* **Performance** + Strong performance in pure software environments, especially where AES-NI + is unavailable. + +* **Modern AEAD** + Aligns the codebase with current best practice for authenticated encryption. + +* **Lower implementation risk** + Fewer edge cases compared to AES-GCM or custom constructions. + +* **Auditability** + Smaller and clearer code paths improve reviewability and long-term + maintenance. + +* **Nonce safety** + The 192-bit nonce space allows safe random nonce generation and preserves + compatibility with existing designs. + +### Cons + +* **Migration cost** + All encrypted data must be re-encrypted during migration. + +* **Temporary dual support** + XSalsa20-Poly1305 must remain supported until migration is complete. + +* **Testing overhead** + Both encryption paths require validation during the transition period. + +* **Passphrase dependency** + Re-encryption is only possible when the user passphrase is available. + +## 4. Status + +Proposed. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index bdd3aa154b..a79ff876a6 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -12,3 +12,5 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0004: Targeted Rescan vs. Global Rewind](./0004-targeted-rescan-vs-rewind.md) - Introduces "Targeted Rescans" to replace global "Rewinds" for more efficient transaction discovery. - [ADR 0005: Explicit Rescan on Import](./0005-no-auto-rescan-on-import.md) - Disables automatic blockchain scanning during import operations, requiring explicit user initiation. - [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. +- [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. +- [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. From 45399852830455b5d2ffaab90121085857bbef24 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 14:30:48 -0300 Subject: [PATCH 122/695] docs: add Single-Passphrase Encryption Model ADR --- .../adr/0009-single-passphrase-encryption.md | 75 +++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 76 insertions(+) create mode 100644 docs/developer/adr/0009-single-passphrase-encryption.md diff --git a/docs/developer/adr/0009-single-passphrase-encryption.md b/docs/developer/adr/0009-single-passphrase-encryption.md new file mode 100644 index 0000000000..4bc2431205 --- /dev/null +++ b/docs/developer/adr/0009-single-passphrase-encryption.md @@ -0,0 +1,75 @@ +# ADR 0009: Single-Passphrase Encryption Model + +## 1. Context + +`btcwallet` is migrating from a key-value database (kvdb) to a SQL-based +backend. This transition requires a clean, consistent encryption model that +works across SQLite and PostgreSQL. + +The legacy system used a dual-passphrase hierarchy with separate public and +private encryption keys. In practice, downstream systems often set the public +passphrase to a known constant to allow auto-start, which negated the privacy +benefits while keeping the complexity. This model also diverges from the +industry standard set by Bitcoin Core and libraries like BDK. + +The design notes that guided this change are captured in +[Single-Passphrase Encryption Design Notes](https://gist.github.com/yyforyongyu/edfeee0f84cf79851735bcc3e740a871). + +## 2. Decision + +We will adopt a **Single-Passphrase Model** that encrypts **private data only** +and leaves public data in plaintext. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[Encrypt Private Data] + E[Public Data] --> F[(Database)] + D --> F +``` + +### Key points + +1. **One master encryption key** derived from the user passphrase +2. **Private data encrypted** (private keys, HD seeds, xprivs, scripts) +3. **Public data plaintext** (addresses, pubkeys, transactions, balances) +4. **Simplified hierarchy** by removing `CryptoPubKey` and `CryptoScriptKey` + +## 3. Consequences + +### Pros + +* **Simplicity**: Eliminates dual passphrase logic. +* **Performance**: Read-only operations no longer require decrypting public + data, reducing query overhead. +* **Interoperability**: Aligns with Bitcoin Core and BDK conventions. +* **UX clarity**: Users only need a single passphrase. + +### Cons + +* **Privacy at rest**: Transaction history, balances, and public keys are + exposed if the database is compromised, consistent with Bitcoin Core tradeoffs. + If stronger protection is required, users can rely on full disk encryption such + as LUKS. +* **Migration effort**: Existing wallets must be migrated away from the legacy + dual passphrase model. This work will be done along with the SQL backend migration. + +## 4. Threat Model and Security Boundaries + +This model protects private key material at rest when an attacker can read the +database but cannot guess the wallet passphrase. + +This model does not protect metadata privacy if the database contents are +exfiltrated. Addresses, balances, and transaction history remain visible by +design. + +Operational mitigations required for this model: + +* Full disk encryption for database files. +* Host hardening (least privilege, patching, endpoint protection). +* Encrypted backups with independent access controls and key management. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index a79ff876a6..e3a3577485 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -14,3 +14,4 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. - [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. - [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. +- [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. From c73192d240cbbbaa54172f5f693855d39f4f719c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 14:33:04 -0300 Subject: [PATCH 123/695] README: align with Single-Passphrase Encryption Model --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b7cb2c5c60..ff6dc97811 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,13 @@ disk. btcwallet uses the HD path for all derived addresses, as described by [BIP0044](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki). -Due to the sensitive nature of public data in a BIP0032 wallet, -btcwallet provides the option of encrypting not just private keys, but -public data as well. This is intended to thwart privacy risks where a -wallet file is compromised without exposing all current and future -addresses (public keys) managed by the wallet. While access to this -information would not allow an attacker to spend or steal coins, it -does mean they could track all transactions involving your addresses -and therefore know your exact balance. In a future release, public data -encryption will extend to transactions as well. +btcwallet encrypts all private key material (private keys, HD seeds) +at rest using a single passphrase. Public data such as addresses, +transactions, and balances is stored in plaintext, consistent with +Bitcoin Core conventions. Users who require stronger privacy at rest +should use full disk encryption (e.g. LUKS). See +[ADR 0009](docs/developer/adr/0009-single-passphrase-encryption.md) +for design rationale. btcwallet is not an SPV client and requires connecting to a local or remote btcd instance for asynchronous blockchain queries and From cd6e9e508e0565070e83182ad24186d24cbafca6 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 12 Feb 2026 18:47:48 -0300 Subject: [PATCH 124/695] wallet: rename db types to match LND naming --- wallet/internal/db/accounts_pg.go | 24 +++++++++---------- wallet/internal/db/accounts_sqlite.go | 24 +++++++++---------- wallet/internal/db/address_types_pg.go | 8 +++---- wallet/internal/db/address_types_sqlite.go | 8 +++---- wallet/internal/db/addresses_pg.go | 24 +++++++++---------- wallet/internal/db/addresses_sqlite.go | 24 +++++++++---------- wallet/internal/db/db_connectors_test.go | 16 ++++++------- wallet/internal/db/itest/pg_test.go | 6 ++--- wallet/internal/db/itest/sqlite_test.go | 6 ++--- wallet/internal/db/pg.go | 12 +++++----- wallet/internal/db/sqlite.go | 12 +++++----- wallet/internal/db/tx.go | 2 +- wallet/internal/db/wallet_pg.go | 28 +++++++++++----------- wallet/internal/db/wallet_sqlite.go | 28 +++++++++++----------- 14 files changed, 111 insertions(+), 111 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 3aad53c15b..4019822067 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -8,25 +8,25 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// Ensure PostgresWalletDB satisfies the AccountStore interface. -var _ AccountStore = (*PostgresWalletDB)(nil) +// Ensure PostgresStore satisfies the AccountStore interface. +var _ AccountStore = (*PostgresStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (w *PostgresWalletDB) GetAccount(ctx context.Context, +func (s *PostgresStore) GetAccount(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - getQueries := pgAccountGetQueries{q: w.queries} + getQueries := pgAccountGetQueries{q: s.queries} return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (w *PostgresWalletDB) ListAccounts(ctx context.Context, +func (s *PostgresStore) ListAccounts(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - listQueries := pgAccountListQueries{q: w.queries} + listQueries := pgAccountListQueries{q: s.queries} return listAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -35,10 +35,10 @@ func (w *PostgresWalletDB) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (w *PostgresWalletDB) RenameAccount(ctx context.Context, +func (s *PostgresStore) RenameAccount(ctx context.Context, params RenameAccountParams) error { - renameQueries := pgAccountRenameQueries{q: w.queries} + renameQueries := pgAccountRenameQueries{q: s.queries} return renameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -48,7 +48,7 @@ func (w *PostgresWalletDB) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, +func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { paramsErr := params.validate() @@ -58,7 +58,7 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, var info *AccountInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { scopeID, err := pgEnsureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -117,12 +117,12 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (w *PostgresWalletDB) CreateImportedAccount(ctx context.Context, +func (s *PostgresStore) CreateImportedAccount(ctx context.Context, params CreateImportedAccountParams) (*AccountProperties, error) { var props *AccountProperties - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error props, err = createImportedAccount( diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index a746a3c6f3..19ce3ad573 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -8,25 +8,25 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// Ensure SQLiteWalletDB satisfies the AccountStore interface. -var _ AccountStore = (*SQLiteWalletDB)(nil) +// Ensure SqliteStore satisfies the AccountStore interface. +var _ AccountStore = (*SqliteStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (w *SQLiteWalletDB) GetAccount(ctx context.Context, +func (s *SqliteStore) GetAccount(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - getQueries := sqliteAccountGetQueries{q: w.queries} + getQueries := sqliteAccountGetQueries{q: s.queries} return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, +func (s *SqliteStore) ListAccounts(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - listQueries := sqliteAccountListQueries{q: w.queries} + listQueries := sqliteAccountListQueries{q: s.queries} return listAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -35,10 +35,10 @@ func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, +func (s *SqliteStore) RenameAccount(ctx context.Context, params RenameAccountParams) error { - renameQueries := sqliteAccountRenameQueries{q: w.queries} + renameQueries := sqliteAccountRenameQueries{q: s.queries} return renameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -48,7 +48,7 @@ func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, +func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { paramsErr := params.validate() @@ -58,7 +58,7 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, var info *AccountInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { scopeID, err := sqliteEnsureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -137,12 +137,12 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (w *SQLiteWalletDB) CreateImportedAccount(ctx context.Context, +func (s *SqliteStore) CreateImportedAccount(ctx context.Context, params CreateImportedAccountParams) (*AccountProperties, error) { var props *AccountProperties - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error props, err = createImportedAccount( diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 242c121a6e..52ee3d6111 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -22,21 +22,21 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( +func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { return listAddressTypes( - ctx, w.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (w *PostgresWalletDB) GetAddressType(ctx context.Context, +func (s *PostgresStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { return getAddressTypeByID( - ctx, w.queries.GetAddressTypeByID, int16(id), id, + ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) } diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index ceda868208..7120c86cde 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -24,21 +24,21 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( +func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { return listAddressTypes( - ctx, w.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, +func (s *SqliteStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { return getAddressTypeByID( - ctx, w.queries.GetAddressTypeByID, int64(id), id, + ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) } diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 78ca288a0c..98c05d19e5 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -11,14 +11,14 @@ import ( // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (w *PostgresWalletDB) GetAddress(ctx context.Context, +func (s *PostgresStore) GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) { getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { return getAddress( - ctx, w.queries.GetAddressByScriptPubKey, + ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), @@ -31,11 +31,11 @@ func (w *PostgresWalletDB) GetAddress(ctx context.Context, // ListAddresses returns a slice of AddressInfo for all addresses in a given // account. -func (w *PostgresWalletDB) ListAddresses(ctx context.Context, +func (s *PostgresStore) ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, error) { return listAddresses( - ctx, w.queries.ListAddressesByAccount, + ctx, s.queries.ListAddressesByAccount, sqlcpg.ListAddressesByAccountParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -46,17 +46,17 @@ func (w *PostgresWalletDB) ListAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (w *PostgresWalletDB) GetAddressSecret(ctx context.Context, +func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { return getAddressSecret( - ctx, w.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } // NewDerivedAddress creates a new address for a given account and key // scope. -func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, +func (s *PostgresStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -65,7 +65,7 @@ func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, sqlcpg.GetAccountByWalletScopeAndNameRow, accountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - getAccount: pgGetAccountFromKey(w.queries), + getAccount: pgGetAccountFromKey(s.queries), accountParams: accountKeyFromParams, getAccountID: newDerivedAddressGetAccountIDPg, getExtIndex: newDerivedAddressGetExtIndexPg, @@ -75,11 +75,11 @@ func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, rowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) + return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. -func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, +func (s *PostgresStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { adapters := importedAddressAdapters[ @@ -89,7 +89,7 @@ func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - getAccount: pgGetAccountFromKey(w.queries), + getAccount: pgGetAccountFromKey(s.queries), accountParams: accountKeyFromImportedParams, getAccountID: newImportedAddressGetAccountIDPg, createAddr: pgCreateImportedAddress, @@ -100,7 +100,7 @@ func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, rowCreatedAt: importedAddressRowCreatedAtPg, } - return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) + return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index 7a36d12434..c5eb7d353b 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -10,14 +10,14 @@ import ( // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (w *SQLiteWalletDB) GetAddress(ctx context.Context, +func (s *SqliteStore) GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) { getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { return getAddress( - ctx, w.queries.GetAddressByScriptPubKey, + ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, @@ -30,11 +30,11 @@ func (w *SQLiteWalletDB) GetAddress(ctx context.Context, // ListAddresses returns a slice of AddressInfo for all addresses in a given // account. -func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, +func (s *SqliteStore) ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, error) { return listAddresses( - ctx, w.queries.ListAddressesByAccount, + ctx, s.queries.ListAddressesByAccount, sqlcsqlite.ListAddressesByAccountParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -45,18 +45,18 @@ func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (w *SQLiteWalletDB) GetAddressSecret(ctx context.Context, +func (s *SqliteStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { return getAddressSecret( - ctx, w.queries.GetAddressSecret, addressID, + ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) } // NewDerivedAddress creates a new address for a given account and key // scope. -func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, +func (s *SqliteStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -65,7 +65,7 @@ func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, sqlcsqlite.GetAccountByWalletScopeAndNameRow, accountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - getAccount: sqliteGetAccountFromKey(w.queries), + getAccount: sqliteGetAccountFromKey(s.queries), accountParams: accountKeyFromParams, getAccountID: newDerivedAddressGetAccountIDSQLite, getExtIndex: newDerivedAddressGetExtIndexSQLite, @@ -75,11 +75,11 @@ func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) + return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. -func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, +func (s *SqliteStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { adapters := importedAddressAdapters[ @@ -89,7 +89,7 @@ func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - getAccount: sqliteGetAccountFromKey(w.queries), + getAccount: sqliteGetAccountFromKey(s.queries), accountParams: accountKeyFromImportedParams, getAccountID: newImportedAddressGetAccountIDSQLite, createAddr: sqliteCreateImportedAddress, @@ -100,7 +100,7 @@ func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, rowCreatedAt: importedAddressRowCreatedAtSQLite, } - return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) + return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 48a4bdd47e..be9bf61508 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -42,16 +42,16 @@ func newMockedTestDB(t *testing.T) *sql.DB { return db } -// TestNewPostgresWalletDB checks that the PostgresWalletDB constructor +// TestNewPostgresStore checks that the PostgresStore constructor // properly guards against nil *sql.DB inputs and wires up the queries // correctly. -func TestNewPostgresWalletDB(t *testing.T) { +func TestNewPostgresStore(t *testing.T) { t.Parallel() t.Run("nil db", func(t *testing.T) { t.Parallel() - db, err := NewPostgresWalletDB(nil) + db, err := NewPostgresStore(nil) require.ErrorIs(t, err, ErrNilDB) require.Nil(t, db) }) @@ -61,7 +61,7 @@ func TestNewPostgresWalletDB(t *testing.T) { sqlDB := newMockedTestDB(t) - db, err := NewPostgresWalletDB(sqlDB) + db, err := NewPostgresStore(sqlDB) require.NoError(t, err) require.NotNil(t, db) require.Equal(t, sqlDB, db.db) @@ -69,16 +69,16 @@ func TestNewPostgresWalletDB(t *testing.T) { }) } -// TestNewSQLiteWalletDB checks that the SQLiteWalletDB constructor +// TestNewSqliteStore checks that the SqliteStore constructor // properly guards against nil *sql.DB inputs and wires up the queries // correctly. -func TestNewSQLiteWalletDB(t *testing.T) { +func TestNewSqliteStore(t *testing.T) { t.Parallel() t.Run("nil db", func(t *testing.T) { t.Parallel() - db, err := NewSQLiteWalletDB(nil) + db, err := NewSqliteStore(nil) require.ErrorIs(t, err, ErrNilDB) require.Nil(t, db) }) @@ -88,7 +88,7 @@ func TestNewSQLiteWalletDB(t *testing.T) { sqlDB := newMockedTestDB(t) - db, err := NewSQLiteWalletDB(sqlDB) + db, err := NewSqliteStore(sqlDB) require.NoError(t, err) require.NotNil(t, db) require.Equal(t, sqlDB, db.db) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index ac892a4b56..f07188959d 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -190,14 +190,14 @@ func NewPostgresDB(t *testing.T) *sql.DB { // NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the // raw sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, +func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, *sql.DB) { t.Helper() dbConn := NewPostgresDB(t) - store, err := db.NewPostgresWalletDB(dbConn) + store, err := db.NewPostgresStore(dbConn) require.NoError(t, err, "failed to create wallet store") queries := sqlcpg.New(dbConn) @@ -206,7 +206,7 @@ func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, } // NewTestStore creates a PostgreSQL wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { +func NewTestStore(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries) { t.Helper() store, queries, _ := NewTestStoreWithDB(t) diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 975872fd13..d29bc21087 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -52,14 +52,14 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // NewTestStoreWithDB creates a SQLite wallet store and also returns the raw // sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, +func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, *sql.DB) { t.Helper() dbConn := NewSQLiteDB(t) - store, err := db.NewSQLiteWalletDB(dbConn) + store, err := db.NewSqliteStore(dbConn) require.NoError(t, err, "failed to create wallet store") queries := sqlcsqlite.New(dbConn) @@ -68,7 +68,7 @@ func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, } // NewTestStore creates the SQLite wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { +func NewTestStore(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries) { t.Helper() store, queries, _ := NewTestStoreWithDB(t) diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 1de6f34f9c..72e87bc8aa 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -7,20 +7,20 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// PostgresWalletDB is the PostgreSQL implementation of the +// PostgresStore is the PostgreSQL implementation of the // WalletStore interface. -type PostgresWalletDB struct { +type PostgresStore struct { db *sql.DB queries *sqlcpg.Queries } -// NewPostgresWalletDB creates a new PostgreSQL-based WalletStore. -func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { +// NewPostgresStore creates a new PostgreSQL-based WalletStore. +func NewPostgresStore(db *sql.DB) (*PostgresStore, error) { if db == nil { return nil, ErrNilDB } - return &PostgresWalletDB{ + return &PostgresStore{ db: db, queries: sqlcpg.New(db), }, nil @@ -30,7 +30,7 @@ func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *PostgresWalletDB) ExecuteTx(ctx context.Context, +func (w *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { return execInTx(ctx, w.db, func(tx *sql.Tx) error { diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 15b8409f8a..48326de7b6 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -7,19 +7,19 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// SQLiteWalletDB is the SQLite implementation of the WalletStore interface. -type SQLiteWalletDB struct { +// SqliteStore is the SQLite implementation of the WalletStore interface. +type SqliteStore struct { db *sql.DB queries *sqlcsqlite.Queries } -// NewSQLiteWalletDB creates a new SQLite-based WalletStore. -func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { +// NewSqliteStore creates a new SQLite-based WalletStore. +func NewSqliteStore(db *sql.DB) (*SqliteStore, error) { if db == nil { return nil, ErrNilDB } - return &SQLiteWalletDB{ + return &SqliteStore{ db: db, queries: sqlcsqlite.New(db), }, nil @@ -29,7 +29,7 @@ func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *SQLiteWalletDB) ExecuteTx(ctx context.Context, +func (w *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { return execInTx(ctx, w.db, func(tx *sql.Tx) error { diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index da65c17deb..a79ac45f31 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresWalletDB and SQLiteWalletDB. It guarantees that the transaction +// PostgresStore and SqliteStore. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index f84a291ce1..38f1bb7ce2 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -9,18 +9,18 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// Ensure PostgresWalletDB satisfies the WalletStore interface. -var _ WalletStore = (*PostgresWalletDB)(nil) +// Ensure PostgresStore satisfies the WalletStore interface. +var _ WalletStore = (*PostgresStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (w *PostgresWalletDB) CreateWallet(ctx context.Context, +func (s *PostgresStore) CreateWallet(ctx context.Context, params CreateWalletParams) (*WalletInfo, error) { var info *WalletInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -111,10 +111,10 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (w *PostgresWalletDB) GetWallet(ctx context.Context, +func (s *PostgresStore) GetWallet(ctx context.Context, name string) (*WalletInfo, error) { - row, err := w.queries.GetWalletByName(ctx, name) + row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, @@ -143,10 +143,10 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, // ListWallets returns a slice of WalletInfo for all wallets stored in // the database. It returns an empty slice if no wallets are found, or // an error if the retrieval fails. -func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, +func (s *PostgresStore) ListWallets(ctx context.Context) ([]WalletInfo, error) { - rows, err := w.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx) if err != nil { return nil, fmt.Errorf("list wallets: %w", err) } @@ -181,10 +181,10 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, +func (s *PostgresStore) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { - return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) @@ -227,10 +227,10 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, +func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", @@ -250,7 +250,7 @@ func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, +func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, params UpdateWalletSecretsParams) error { secretsParams := sqlcpg.UpdateWalletSecretsParams{ @@ -261,7 +261,7 @@ func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) if err != nil { return fmt.Errorf("update wallet secrets: %w", err) } diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index e7d7ff45ff..dae1a75308 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -9,18 +9,18 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// Ensure SQLiteWalletDB satisfies the WalletStore interface. -var _ WalletStore = (*SQLiteWalletDB)(nil) +// Ensure SqliteStore satisfies the WalletStore interface. +var _ WalletStore = (*SqliteStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, +func (s *SqliteStore) CreateWallet(ctx context.Context, params CreateWalletParams) (*WalletInfo, error) { var info *WalletInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -111,10 +111,10 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (w *SQLiteWalletDB) GetWallet(ctx context.Context, +func (s *SqliteStore) GetWallet(ctx context.Context, name string) (*WalletInfo, error) { - row, err := w.queries.GetWalletByName(ctx, name) + row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, @@ -143,10 +143,10 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, // ListWallets returns a slice of WalletInfo for all wallets stored in // the database. It returns an empty slice if no wallets are found, or // an error if the retrieval fails. -func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, +func (s *SqliteStore) ListWallets(ctx context.Context) ([]WalletInfo, error) { - rows, err := w.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx) if err != nil { return nil, fmt.Errorf("list wallets: %w", err) } @@ -181,10 +181,10 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, +func (s *SqliteStore) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { - return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExistsSqlite( @@ -226,10 +226,10 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, +func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", @@ -249,7 +249,7 @@ func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, +func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, params UpdateWalletSecretsParams) error { secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ @@ -260,7 +260,7 @@ func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) if err != nil { return fmt.Errorf("update wallet secrets: %w", err) } From de762f088e085f72b4268abce51ad6e26bda3dc2 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 12 Feb 2026 18:52:49 -0300 Subject: [PATCH 125/695] wallet: add config structs for sqlite and postgres --- wallet/internal/db/config.go | 90 +++++++++++++++++ wallet/internal/db/config_test.go | 162 ++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 wallet/internal/db/config.go create mode 100644 wallet/internal/db/config_test.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go new file mode 100644 index 0000000000..0df15b22b9 --- /dev/null +++ b/wallet/internal/db/config.go @@ -0,0 +1,90 @@ +package db + +import ( + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + // DefaultMaxConnections is the default maximum number of permitted + // connections (both active and idle) to the database. We want to limit + // this so it isn't unlimited. The same value is used for the maximum + // number of idle connections, which can improve performance by avoiding + // the overhead of establishing a new connection for each query. + DefaultMaxConnections = 25 + + // DefaultConnIdleLifetime is the default amount of time a connection + // can be idle before being closed. + DefaultConnIdleLifetime = 5 * time.Minute + + // DefaultConnectionTimeout is the default timeout for establishing + // a new database connection. + DefaultConnectionTimeout = 5 * time.Second +) + +var ( + // ErrEmptyDBPath is returned when an empty database path is provided. + ErrEmptyDBPath = errors.New("database path cannot be empty") + + // ErrNegativeMaxConns is returned when MaxConnections is negative. + ErrNegativeMaxConns = errors.New("max connections must be non-negative") + + // ErrEmptyDSN is returned when the DSN string is empty. + ErrEmptyDSN = errors.New("DSN is required") +) + +// SqliteConfig holds the configuration for the SQLite database. +type SqliteConfig struct { + // DBPath is the filesystem path to the SQLite database file. + DBPath string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the SqliteConfig values are valid. +func (c *SqliteConfig) Validate() error { + if c.DBPath == "" { + return ErrEmptyDBPath + } + + if c.MaxConnections < 0 { + return ErrNegativeMaxConns + } + + return nil +} + +// PostgresConfig holds the configuration for the PostgreSQL database. +type PostgresConfig struct { + // Dsn is the database connection string. + Dsn string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the PostgresConfig values are valid. +func (c *PostgresConfig) Validate() error { + if c.Dsn == "" { + return ErrEmptyDSN + } + + // Parse the DSN using pgx to ensure it's a valid PostgreSQL + // connection string. + _, err := pgx.ParseConfig(c.Dsn) + if err != nil { + return fmt.Errorf("invalid DSN: %w", err) + } + + if c.MaxConnections < 0 { + return ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/config_test.go new file mode 100644 index 0000000000..142f8925a8 --- /dev/null +++ b/wallet/internal/db/config_test.go @@ -0,0 +1,162 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSqliteConfigValidateSuccess tests valid SqliteConfig scenarios. +func TestSqliteConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SqliteConfig + }{ + { + name: "valid config with zero max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: 0, + }, + }, + { + name: "valid config with positive max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: 10, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestSqliteConfigValidateErrors tests SqliteConfig validation errors. +func TestSqliteConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SqliteConfig + expectedErr error + }{ + { + name: "empty DB path", + config: SqliteConfig{ + DBPath: "", + MaxConnections: 0, + }, + expectedErr: ErrEmptyDBPath, + }, + { + name: "negative max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + expectedErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. +func TestPostgresConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config PostgresConfig + }{ + { + name: "valid config with all fields set", + config: PostgresConfig{ + Dsn: "postgres://user:pass@localhost/db", + MaxConnections: 25, + }, + }, + { + name: "valid config with zero max connections", + config: PostgresConfig{ + Dsn: "postgres://localhost/db", + MaxConnections: 0, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestPostgresConfigValidateErrors tests PostgresConfig validation errors. +func TestPostgresConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config PostgresConfig + expectedErr error + expectAnyError bool + }{ + { + name: "empty DSN", + config: PostgresConfig{ + Dsn: "", + MaxConnections: 10, + }, + expectedErr: ErrEmptyDSN, + }, + { + name: "invalid DSN format", + config: PostgresConfig{ + Dsn: "://invalid", + MaxConnections: 10, + }, + expectAnyError: true, + }, + { + name: "negative max connections", + config: PostgresConfig{ + Dsn: "postgres://localhost/db", + MaxConnections: -5, + }, + expectedErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + if tc.expectAnyError { + require.Error(t, err) + } else { + require.ErrorIs(t, err, tc.expectedErr) + } + }) + } +} From 2477f8031acaf0a376728c8a7b6ec41f466d0456 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 16 Feb 2026 18:34:17 -0300 Subject: [PATCH 126/695] wallet: refactor db constructors --- wallet/internal/db/db_connectors_test.go | 179 +++++++++++------------ wallet/internal/db/db_itest.go | 15 ++ wallet/internal/db/itest/pg_test.go | 30 ++-- wallet/internal/db/itest/sqlite_test.go | 39 ++--- wallet/internal/db/pg.go | 64 +++++++- wallet/internal/db/sqlite.go | 70 ++++++++- 6 files changed, 244 insertions(+), 153 deletions(-) create mode 100644 wallet/internal/db/db_itest.go diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index be9bf61508..8c9ec46d55 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -1,116 +1,113 @@ package db import ( - "database/sql" - "database/sql/driver" - "sync" + "path/filepath" "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -const testDriverName = "wallet-test-driver" - -var ( - registerDriverOnce sync.Once - testDriver *mockDriver -) - -// newMockedTestDB returns a *sql.DB backed by a mock driver. It avoids any -// network or disk usage, so it works well for constructor tests that only -// need a non nil database handle. It should be used only in very simple -// scenarios, since it does not implement real behavior and cannot confirm -// that the issued queries works as expected. -func newMockedTestDB(t *testing.T) *sql.DB { - t.Helper() - - registerDriverOnce.Do(func() { - testDriver = &mockDriver{} - testDriver.On("Open", mock.Anything).Return(mockConn{}, nil) - - sql.Register(testDriverName, testDriver) - }) - - db, err := sql.Open(testDriverName, "") - require.NoError(t, err) - - t.Cleanup(func() { - _ = db.Close() - }) +func TestNewPostgresStoreValidateConfig(t *testing.T) { + t.Parallel() - return db + tests := []struct { + name string + cfg PostgresConfig + wantErr error + }{ + { + name: "empty DSN", + cfg: PostgresConfig{ + Dsn: "", + }, + wantErr: ErrEmptyDSN, + }, + { + name: "negative max connections", + cfg: PostgresConfig{ + Dsn: "postgres://test", + MaxConnections: -1, + }, + wantErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, err := NewPostgresStore(t.Context(), tc.cfg) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, store) + }) + } } -// TestNewPostgresStore checks that the PostgresStore constructor -// properly guards against nil *sql.DB inputs and wires up the queries -// correctly. -func TestNewPostgresStore(t *testing.T) { +func TestNewPostgresStoreConnectionFailure(t *testing.T) { t.Parallel() - t.Run("nil db", func(t *testing.T) { - t.Parallel() - - db, err := NewPostgresStore(nil) - require.ErrorIs(t, err, ErrNilDB) - require.Nil(t, db) - }) + // Valid config, but hits a connection failure. + cfg := PostgresConfig{ + Dsn: "postgres://localhost:1/testdb", + } - t.Run("valid db", func(t *testing.T) { - t.Parallel() + store, err := NewPostgresStore(t.Context(), cfg) + require.Error(t, err) + require.ErrorContains(t, err, "ping database") + require.NotErrorIs(t, err, ErrEmptyDSN) + require.NotErrorIs(t, err, ErrNegativeMaxConns) - sqlDB := newMockedTestDB(t) - - db, err := NewPostgresStore(sqlDB) - require.NoError(t, err) - require.NotNil(t, db) - require.Equal(t, sqlDB, db.db) - require.NotNil(t, db.queries) - }) + // We are asserting nil here because it's not an integration test, so we + // are not able to create a postgres database and connect to it. + require.Nil(t, store) } -// TestNewSqliteStore checks that the SqliteStore constructor -// properly guards against nil *sql.DB inputs and wires up the queries -// correctly. -func TestNewSqliteStore(t *testing.T) { +func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Parallel() - t.Run("nil db", func(t *testing.T) { - t.Parallel() - - db, err := NewSqliteStore(nil) - require.ErrorIs(t, err, ErrNilDB) - require.Nil(t, db) - }) - - t.Run("valid db", func(t *testing.T) { - t.Parallel() - - sqlDB := newMockedTestDB(t) - - db, err := NewSqliteStore(sqlDB) - require.NoError(t, err) - require.NotNil(t, db) - require.Equal(t, sqlDB, db.db) - require.NotNil(t, db.queries) - }) + tests := []struct { + name string + cfg SqliteConfig + wantErr error + }{ + { + name: "empty DB path", + cfg: SqliteConfig{ + DBPath: "", + }, + wantErr: ErrEmptyDBPath, + }, + { + name: "negative max connections", + cfg: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + wantErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, err := NewSqliteStore(t.Context(), tc.cfg) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, store) + }) + } } -// mockDriver implements a bare-bones SQL driver so tests can obtain a *sql.DB -// without depending on an external database. -type mockDriver struct { - mock.Mock -} +func TestNewSqliteStoreSuccess(t *testing.T) { + t.Parallel() -func (m *mockDriver) Open(name string) (driver.Conn, error) { - args := m.Called(name) - conn, _ := args.Get(0).(driver.Conn) + cfg := SqliteConfig{ + DBPath: filepath.Join(t.TempDir(), "wallet.db"), + } - return conn, args.Error(1) -} + store, err := NewSqliteStore(t.Context(), cfg) + require.NoError(t, err) + require.NotNil(t, store) -// mockConn is a mock implementation of a database connection. It does not -// implement any real behavior. Used to be returned by the mockDriver. -type mockConn struct { - mock.Mock + require.NoError(t, store.Close()) } diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go new file mode 100644 index 0000000000..0b04012482 --- /dev/null +++ b/wallet/internal/db/db_itest.go @@ -0,0 +1,15 @@ +//go:build itest + +package db + +import "database/sql" + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *SqliteStore) DB() *sql.DB { + return s.db +} + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *PostgresStore) DB() *sql.DB { + return s.db +} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f07188959d..6af26cc2e5 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -133,7 +133,7 @@ func sanitizedPgDBName(t *testing.T) string { // NewPostgresDB creates a new PostgreSQL database connection with migrations // applied. Each test gets its own database for isolation. -func NewPostgresDB(t *testing.T) *sql.DB { +func NewPostgresDB(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() @@ -170,22 +170,19 @@ func NewPostgresDB(t *testing.T) *sql.DB { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - // TODO(gustavostingelin): replace with the real PostgreSQL database - // connection constructor when available. - dbConn, err := sql.Open("pgx", testConnStr) - require.NoError(t, err, "failed to open test database connection") - require.NotNil(t, dbConn, "test database connection is nil") + cfg := db.PostgresConfig{ + Dsn: testConnStr, + MaxConnections: 0, + } + + store, err := db.NewPostgresStore(t.Context(), cfg) + require.NoError(t, err, "failed to create postgres store") - // Close the connection to avoid leaking an idle connection during tests. - // The container is reused across all tests, so we explicitly clean this up. t.Cleanup(func() { - _ = dbConn.Close() + _ = store.Close() }) - err = db.ApplyPostgresMigrations(dbConn) - require.NoError(t, err, "failed to apply migrations") - - return dbConn + return store } // NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the @@ -195,11 +192,8 @@ func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, t.Helper() - dbConn := NewPostgresDB(t) - - store, err := db.NewPostgresStore(dbConn) - require.NoError(t, err, "failed to create wallet store") - + store := NewPostgresDB(t) + dbConn := store.DB() queries := sqlcpg.New(dbConn) return store, queries, dbConn diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index d29bc21087..2a4d0724a1 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -15,39 +15,25 @@ import ( // NewSQLiteDB creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewSQLiteDB(t *testing.T) *sql.DB { +func NewSQLiteDB(t *testing.T) *db.SqliteStore { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - // Enable foreign keys (required for proper constraint enforcement). - dsn := dbPath + "?_pragma=foreign_keys=on" + cfg := db.SqliteConfig{ + DBPath: dbPath, + MaxConnections: 0, + } - // Enable WAL mode for better concurrency. WAL allows multiple readers and - // reduces lock contention for concurrent writers. - dsn = dsn + "&_pragma=journal_mode=WAL" - - // Enable immediate transaction locking to avoid races. - dsn = dsn + "&_txlock=immediate" - - // Set busy timeout to 5 seconds. This makes SQLite retry acquiring locks - // instead of immediately returning SQLITE_BUSY errors. - dsn = dsn + "&_pragma=busy_timeout=5000" - - // TODO(gustavostingelin): replace with the real SQLite database - // connection constructor when available. - dbConn, err := sql.Open("sqlite", dsn) - require.NoError(t, err, "failed to open sqlite database") - - err = db.ApplySQLiteMigrations(dbConn) - require.NoError(t, err, "failed to apply migrations") + store, err := db.NewSqliteStore(t.Context(), cfg) + require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { - _ = dbConn.Close() + _ = store.Close() }) - return dbConn + return store } // NewTestStoreWithDB creates a SQLite wallet store and also returns the raw @@ -57,11 +43,8 @@ func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, t.Helper() - dbConn := NewSQLiteDB(t) - - store, err := db.NewSqliteStore(dbConn) - require.NoError(t, err, "failed to create wallet store") - + store := NewSQLiteDB(t) + dbConn := store.DB() queries := sqlcsqlite.New(dbConn) return store, queries, dbConn diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 72e87bc8aa..60ac84fd3e 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -3,8 +3,10 @@ package db import ( "context" "database/sql" + "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) // PostgresStore is the PostgreSQL implementation of the @@ -14,27 +16,73 @@ type PostgresStore struct { queries *sqlcpg.Queries } -// NewPostgresStore creates a new PostgreSQL-based WalletStore. -func NewPostgresStore(db *sql.DB) (*PostgresStore, error) { - if db == nil { - return nil, ErrNilDB +// NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles +// the full connection setup including config validation, connection opening, +// health checks, connection pool configuration, and migration application. +func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, + error) { + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + db, err := sql.Open("pgx", cfg.Dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + defer cancel() + + err = db.PingContext(connCtx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + maxConns := DefaultMaxConnections + if cfg.MaxConnections > 0 { + maxConns = cfg.MaxConnections + } + + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + + queries := sqlcpg.New(db) + + err = ApplyPostgresMigrations(db) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply migrations: %w", err) } return &PostgresStore{ db: db, - queries: sqlcpg.New(db), + queries: queries, }, nil } +// Close closes the database connection. +func (s *PostgresStore) Close() error { + err := s.db.Close() + if err != nil { + return fmt.Errorf("close database: %w", err) + } + + return nil +} + // ExecuteTx executes a function within a database transaction. The function // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *PostgresStore) ExecuteTx(ctx context.Context, +func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return execInTx(ctx, w.db, func(tx *sql.Tx) error { - qtx := w.queries.WithTx(tx) + return execInTx(ctx, s.db, func(tx *sql.Tx) error { + qtx := s.queries.WithTx(tx) return fn(qtx) }) } diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 48326de7b6..b6af1a94d1 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -3,8 +3,10 @@ package db import ( "context" "database/sql" + "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) // SqliteStore is the SQLite implementation of the WalletStore interface. @@ -13,27 +15,79 @@ type SqliteStore struct { queries *sqlcsqlite.Queries } -// NewSqliteStore creates a new SQLite-based WalletStore. -func NewSqliteStore(db *sql.DB) (*SqliteStore, error) { - if db == nil { - return nil, ErrNilDB +// NewSqliteStore creates a new SQLite-based WalletStore. It handles the full +// connection setup including DSN construction with pragmas, connection +// opening, health checks, connection pool configuration, and migration +// application. +func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, + error) { + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + dsn := cfg.DBPath + "?_pragma=foreign_keys=on" + dsn += "&_pragma=journal_mode=WAL" + dsn += "&_txlock=immediate" + dsn += "&_pragma=busy_timeout=5000" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + defer cancel() + + err = db.PingContext(connCtx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + maxConns := DefaultMaxConnections + if cfg.MaxConnections > 0 { + maxConns = cfg.MaxConnections + } + + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + + queries := sqlcsqlite.New(db) + + err = ApplySQLiteMigrations(db) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply migrations: %w", err) } return &SqliteStore{ db: db, - queries: sqlcsqlite.New(db), + queries: queries, }, nil } +// Close closes the database connection. +func (s *SqliteStore) Close() error { + err := s.db.Close() + if err != nil { + return fmt.Errorf("close database: %w", err) + } + + return nil +} + // ExecuteTx executes a function within a database transaction. The function // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *SqliteStore) ExecuteTx(ctx context.Context, +func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return execInTx(ctx, w.db, func(tx *sql.Tx) error { - qtx := w.queries.WithTx(tx) + return execInTx(ctx, s.db, func(tx *sql.Tx) error { + qtx := s.queries.WithTx(tx) return fn(qtx) }) } From e21edfad09d9e62e93dede07ca95f1ccb83f891f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 17 Feb 2026 14:31:42 -0300 Subject: [PATCH 127/695] wallet: add itest accessor avoiding return tuple --- wallet/internal/db/db_itest.go | 17 ++++++- .../internal/db/itest/account_store_test.go | 35 +++++++------- .../internal/db/itest/address_store_test.go | 48 +++++++++++-------- .../db/itest/address_types_store_test.go | 4 +- wallet/internal/db/itest/fixtures_pg_test.go | 15 ++++++ .../internal/db/itest/fixtures_sqlite_test.go | 15 ++++++ wallet/internal/db/itest/pg_test.go | 29 +---------- wallet/internal/db/itest/sqlite_test.go | 30 +----------- wallet/internal/db/itest/wallet_store_test.go | 32 +++++++------ 9 files changed, 114 insertions(+), 111 deletions(-) diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go index 0b04012482..85ca82369f 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/db_itest.go @@ -2,14 +2,29 @@ package db -import "database/sql" +import ( + "database/sql" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) // DB returns the underlying *sql.DB connection for integration testing. func (s *SqliteStore) DB() *sql.DB { return s.db } +// Queries returns the underlying sqlc queries for integration testing. +func (s *SqliteStore) Queries() *sqlcsqlite.Queries { + return s.queries +} + // DB returns the underlying *sql.DB connection for integration testing. func (s *PostgresStore) DB() *sql.DB { return s.db } + +// Queries returns the underlying sqlc queries for integration testing. +func (s *PostgresStore) Queries() *sqlcpg.Queries { + return s.queries +} diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 02789da0b8..98b39be250 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -21,7 +21,7 @@ import ( // across all standard key scopes between multiple wallets. func TestCreateAccounts(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) // Create 3 wallets to ensure wallet_id scoping works. for i := range 3 { @@ -51,7 +51,7 @@ func TestCreateAccounts(t *testing.T) { func TestCreateDerivedAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-create-derived-account-errors") @@ -96,7 +96,7 @@ func TestCreateDerivedAccountErrors(t *testing.T) { func TestCreateDerivedAccountDuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "duplicate-name-wallet") @@ -120,7 +120,7 @@ func TestCreateDerivedAccountDuplicateName(t *testing.T) { func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "multi-scope-wallet") @@ -151,7 +151,7 @@ func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "sequential-wallet") @@ -177,7 +177,7 @@ func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { func TestCreateDerivedAccountConcurrent(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "concurrent-wallet") @@ -222,7 +222,7 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { func TestCreateImportedAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-create-imported-account-errors") @@ -279,7 +279,7 @@ func TestCreateImportedAccountErrors(t *testing.T) { func TestCreateImportedAccountDuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "imported-duplicate-name-wallet") @@ -306,7 +306,7 @@ func TestCreateImportedAccountDuplicateName(t *testing.T) { func TestGetAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-account") @@ -345,7 +345,7 @@ func TestGetAccount(t *testing.T) { func TestGetAccountNotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-account-not-found") @@ -380,7 +380,7 @@ func TestListAccounts(t *testing.T) { // Ensure that has at least 3 accounts to be tested. require.GreaterOrEqual(t, len(AllAccountCases), 3) - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-accounts") @@ -454,7 +454,7 @@ func TestListAccounts(t *testing.T) { func TestListAccountsOrdering(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-ordering") @@ -493,7 +493,7 @@ func TestListAccountsOrdering(t *testing.T) { func TestAccountCreatedAtTimestamp(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-created-at") @@ -534,7 +534,7 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { func TestRenameAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-rename-account") @@ -595,7 +595,7 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-rename-account-errors") @@ -667,11 +667,10 @@ func TestRenameAccountErrors(t *testing.T) { func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-max-account") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") - scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") + setupMaxAccountNumberTest(t, store, walletID) // This should succeed with account_number = MaxUint32. info, err := store.CreateDerivedAccount( diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 01b2453877..87f6e11745 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -88,7 +88,8 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, func TestNewImportedAddress(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() walletID := newWallet(t, store, "wallet-imported-addresses") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") @@ -231,7 +232,8 @@ func TestNewImportedAddress(t *testing.T) { func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") @@ -339,7 +341,8 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { func TestImportedAddressCounterInsertDelete(t *testing.T) { t.Parallel() - store, _, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -386,7 +389,8 @@ func TestImportedAddressCounterInsertDelete(t *testing.T) { func TestImportedAddressCounterConcurrentInsert(t *testing.T) { t.Parallel() - store, _, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter-concurrent") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -468,7 +472,7 @@ func TestImportedAddressCounterConcurrentInsert(t *testing.T) { func TestNewImportedAddressDuplicate(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-duplicate-import") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -501,7 +505,7 @@ func TestNewImportedAddressDuplicate(t *testing.T) { func TestGetAddressSecret(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-secrets") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") @@ -568,7 +572,7 @@ func TestGetAddressSecret(t *testing.T) { func TestGetAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-address") tests := []struct { @@ -654,7 +658,7 @@ func TestGetAddress(t *testing.T) { func TestListAddresses(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-addresses") tests := []struct { @@ -772,7 +776,7 @@ func TestListAddresses(t *testing.T) { func TestNewDerivedAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-derived") // Create account in BIP44 scope. @@ -824,7 +828,7 @@ func TestNewDerivedAddress(t *testing.T) { func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "test-wallet") // Attempt to import address when "imported" account doesn't exist. @@ -851,7 +855,7 @@ func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { func TestGetAddressSecret_DerivedAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "test-wallet") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "test-account") @@ -879,7 +883,7 @@ func TestGetAddressSecret_DerivedAddress(t *testing.T) { func TestNewDerivedAddressSequentialIndexes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-sequential-indexes") // Create derived account for the test. @@ -901,7 +905,7 @@ func TestNewDerivedAddressSequentialIndexes(t *testing.T) { func TestListAddressesOrdering(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-ordering") createDerivedAccount( @@ -962,7 +966,7 @@ func TestListAddressesOrdering(t *testing.T) { func TestNewDerivedAddressErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-new-derived-address-errors") tests := []struct { @@ -1021,7 +1025,7 @@ func TestNewDerivedAddressErrors(t *testing.T) { func TestNewDerivedAddressConcurrent(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "concurrent-wallet") accountName := "concurrent-account" @@ -1075,7 +1079,7 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { func TestNewDerivedAddressBranchIsolation(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-branch-isolation") // Create derived account for the test. @@ -1119,7 +1123,7 @@ func TestNewDerivedAddressBranchIsolation(t *testing.T) { func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-account-key-counts") accountName := "counted-account" @@ -1145,7 +1149,7 @@ func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { func TestNewDerivedAddressBranchCounters(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-branch-counters") accountName := "branch-counter-account" @@ -1178,7 +1182,9 @@ func TestNewDerivedAddressBranchCounters(t *testing.T) { func TestNewDerivedAddressMaxIndex(t *testing.T) { t.Parallel() - store, queries, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + queries := store.Queries() + dbConn := store.DB() walletID := newWallet(t, store, "wallet-max-index") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1216,7 +1222,9 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { t.Parallel() - store, queries, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + queries := store.Queries() + dbConn := store.DB() walletID := newWallet(t, store, "wallet-max-index-internal") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index 16586a8224..fec40e59e6 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -12,7 +12,7 @@ import ( func TestListAddressTypes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) types, err := store.ListAddressTypes(t.Context()) require.NoError(t, err) @@ -33,7 +33,7 @@ func TestListAddressTypes(t *testing.T) { func TestGetAddressType(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) tests := []struct { id db.AddressType diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 83b77b09a4..d496f98c55 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "fmt" + "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -182,3 +183,17 @@ func deleteAddress(ctx context.Context, dbConn *sql.DB, return nil } + +func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, + walletID uint32) { + + t.Helper() + + require.IsType(t, &db.PostgresStore{}, store) + + pgStore := store.(*db.PostgresStore) + queries := pgStore.Queries() + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, + "account-near-max") +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 034c30abe3..8e3a9d568f 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "fmt" + "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -182,3 +183,17 @@ func deleteAddress(ctx context.Context, dbConn *sql.DB, return nil } + +func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, + walletID uint32) { + + t.Helper() + + require.IsType(t, &db.SqliteStore{}, store) + + sqliteStore := store.(*db.SqliteStore) + queries := sqliteStore.Queries() + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, + "account-near-max") +} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 6af26cc2e5..d8384d806a 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,8 +14,6 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -131,9 +129,9 @@ func sanitizedPgDBName(t *testing.T) string { return dbName } -// NewPostgresDB creates a new PostgreSQL database connection with migrations +// NewTestStore creates a new PostgreSQL database connection with migrations // applied. Each test gets its own database for isolation. -func NewPostgresDB(t *testing.T) *db.PostgresStore { +func NewTestStore(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() @@ -184,26 +182,3 @@ func NewPostgresDB(t *testing.T) *db.PostgresStore { return store } - -// NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the -// raw sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, - *sql.DB) { - - t.Helper() - - store := NewPostgresDB(t) - dbConn := store.DB() - queries := sqlcpg.New(dbConn) - - return store, queries, dbConn -} - -// NewTestStore creates a PostgreSQL wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries) { - t.Helper() - - store, queries, _ := NewTestStoreWithDB(t) - - return store, queries -} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 2a4d0724a1..30bc4f0797 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -3,19 +3,16 @@ package itest import ( - "database/sql" "path/filepath" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" - _ "modernc.org/sqlite" ) -// NewSQLiteDB creates a new SQLite database for testing with migrations +// NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewSQLiteDB(t *testing.T) *db.SqliteStore { +func NewTestStore(t *testing.T) *db.SqliteStore { t.Helper() tmpDir := t.TempDir() @@ -35,26 +32,3 @@ func NewSQLiteDB(t *testing.T) *db.SqliteStore { return store } - -// NewTestStoreWithDB creates a SQLite wallet store and also returns the raw -// sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, - *sql.DB) { - - t.Helper() - - store := NewSQLiteDB(t) - dbConn := store.DB() - queries := sqlcsqlite.New(dbConn) - - return store, queries, dbConn -} - -// NewTestStore creates the SQLite wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries) { - t.Helper() - - store, queries, _ := NewTestStoreWithDB(t) - - return store, queries -} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 5408b66bc5..fcc098d9b1 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -15,7 +15,7 @@ import ( func TestCreateWallet(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("test-wallet") info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) @@ -37,7 +37,7 @@ func TestCreateWallet(t *testing.T) { func TestCreateWallet_WithBirthday(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("birthday-wallet") birthday := time.Now().UTC().Add(-30 * 24 * time.Hour) @@ -56,7 +56,7 @@ func TestCreateWallet_WithBirthday(t *testing.T) { func TestCreateWallet_DuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("duplicate-wallet") _, err := store.CreateWallet(t.Context(), params) @@ -100,7 +100,7 @@ func TestCreateWallet_Variants(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := tc.params(tc.name) info, err := store.CreateWallet(t.Context(), params) @@ -116,7 +116,7 @@ func TestCreateWallet_Variants(t *testing.T) { func TestGetWallet(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("get-test-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -138,7 +138,7 @@ func TestGetWallet(t *testing.T) { func TestGetWallet_NotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) _, err := store.GetWallet(t.Context(), "non-existent-wallet") require.Error(t, err) @@ -149,7 +149,7 @@ func TestGetWallet_NotFound(t *testing.T) { func TestListWallets(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) // Initially empty. wallets, err := store.ListWallets(t.Context()) @@ -181,7 +181,8 @@ func TestListWallets(t *testing.T) { func TestUpdateWallet_SyncedTo(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() params := CreateWalletParamsFixture("update-sync-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -215,7 +216,8 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { func TestUpdateWallet_BirthdayBlock(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() params := CreateWalletParamsFixture("update-birthday-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -255,7 +257,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { func TestUpdateWallet_Birthday(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("birthday-timestamp-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -288,7 +290,7 @@ func TestUpdateWallet_Birthday(t *testing.T) { func TestUpdateWallet_NotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) updateParams := db.UpdateWalletParams{ WalletID: 99999, // Non-existent ID. @@ -303,7 +305,7 @@ func TestUpdateWallet_NotFound(t *testing.T) { func TestGetEncryptedHDSeed(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("seed-wallet") expectedSeed := params.EncryptedMasterPrivKey @@ -321,7 +323,7 @@ func TestGetEncryptedHDSeed(t *testing.T) { func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWatchOnlyWalletParams("watch-only-seed") created, err := store.CreateWallet(t.Context(), params) @@ -337,7 +339,7 @@ func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { func TestUpdateWalletSecrets(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("secrets-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -364,7 +366,7 @@ func TestUpdateWalletSecrets(t *testing.T) { func TestUpdateWallet_AutoBlockInsertion(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("auto-block-wallet") created, err := store.CreateWallet(t.Context(), params) From b9a7b3de69c4a3cb3c431e003632225d65efdeb8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 3 Mar 2026 16:07:25 -0300 Subject: [PATCH 128/695] wallet: add itest for store creation --- wallet/internal/db/itest/store_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 wallet/internal/db/itest/store_test.go diff --git a/wallet/internal/db/itest/store_test.go b/wallet/internal/db/itest/store_test.go new file mode 100644 index 0000000000..6417ede983 --- /dev/null +++ b/wallet/internal/db/itest/store_test.go @@ -0,0 +1,24 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewTestStore(t *testing.T) { + t.Parallel() + + // This test store exercises the underlying database connector in a test + // environment, so we can verify that the store is created successfully with + // a valid database connection and later properly closed. Will test all + // backends (SQLite, PostgreSQL) based in the build tags. + store := NewTestStore(t) + + require.NotNil(t, store) + require.NotNil(t, store.DB()) + require.NotNil(t, store.Queries()) + require.NoError(t, store.Close()) +} From 45cf0298da1a255eb8db911e04f4d346ed5271ac Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 19:16:32 -0300 Subject: [PATCH 129/695] docs: add keyvault ADR --- .../adr/0010-keyvault-encryption-layer.md | 108 ++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 109 insertions(+) create mode 100644 docs/developer/adr/0010-keyvault-encryption-layer.md diff --git a/docs/developer/adr/0010-keyvault-encryption-layer.md b/docs/developer/adr/0010-keyvault-encryption-layer.md new file mode 100644 index 0000000000..0b7f48fd86 --- /dev/null +++ b/docs/developer/adr/0010-keyvault-encryption-layer.md @@ -0,0 +1,108 @@ +# ADR 0010: Keyvault Encryption Layer + +## 1. Context + +The updated encryption model defined in ADR 0009 and the planned +cryptographic primitive migration proposed in ADR 0007 require a clear +boundary between domain logic and the SQL database layer. The +legacy `waddrmgr` design tightly couples storage, locking, and key derivation, +which complicates the SQL migration and makes encryption behavior hard to test +in isolation. + +To address this, we need a dedicated component that owns lock state, key +derivation, and encryption, while keeping the database layer strictly +encryption agnostic. + +The `db.Store` remains available to other callers for non-cryptographic +queries and updates. + +## 2. Decision + +We will introduce a dedicated **`wallet/internal/keyvault`** package that +defines the encryption boundary between domain code and the SQL store layer. +Keyvault accesses the database through `db.Store`, not by talking directly +to the SQL backend. + +```mermaid +flowchart TD + A[Domain Code] -->|crypto ops| B[keyvault] + A -->|non-crypto ops| E[db.Store] + B -->|derive| C[btcutil/hdkeychain] + B -->|decrypt/encrypt| F[XChaCha20Poly1305
XSalsa20Poly1305] + B -->|read/write| E + B -->|read/write| H[(memory cache)] + E --> D[(SQL DB)] + E --> G[(kvdb)] + classDef store fill:#f8f9fb,stroke:#9aa0a6,stroke-width:1px + class G,D store +``` + +### Responsibilities + +1. **Own lock state and key lifecycle** + Centralized management of unlock state, key derivation, key material + lifetime, and secure memory zeroing. + +2. **Expose typed domain interfaces** + Methods return `*btcec.PrivateKey`, `*btcec.PublicKey`, and + `btcutil.Address` instead of encrypted `[]byte` values. + +3. **Handle HD derivation** + Use `btcutil/hdkeychain` for BIP32 and BIP44 derivation and return or + persist derived keys as needed. + +4. **Maintain an in-memory cache** + Cache account level and derived keys to avoid repeated derivation and + database reads. + +5. **Support multi-wallet operation** + A single keyvault instance manages multiple wallets via `wallet_id` + parameters. + +6. **Track current and planned cryptographic primitives** + Encryption follows the accepted single-passphrase model in ADR 0009 and + adopts ADR 0007 once the XChaCha20-Poly1305 migration is implemented. + +7. **Coexist with `waddrmgr` during migration** + New code uses keyvault while legacy code continues to rely on + `waddrmgr` until the migration is complete. + +## 3. Consequences + +### Pros + +* **Separation of concerns** + Database code stores opaque bytes without knowledge of cryptography or lock + state. + +* **Type safety** + Callers work with strongly typed keys rather than raw blobs. + +* **Centralized lock management** + Prevents lock state divergence across components. + +* **Performance** + Caching reduces repeated derivation and database access. + +* **Testability** + Keyvault can be tested against mock databases, and domain logic can be + tested using mock keyvault implementations. + +* **Memory safety** + Secure memory handling and zeroing are centralized in one place. + +### Cons + +* **Additional abstraction** + Introduces a new package boundary that must be maintained. + +* **Migration cost** + Existing code paths must be refactored to use the new keyvault API. + +* **Temporary dual systems** + `waddrmgr` and keyvault will coexist during the transition period, + increasing short-term complexity. + +## 4. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index e3a3577485..3b569e8123 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -15,3 +15,4 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. - [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. - [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. +- [ADR 0010: Keyvault Encryption Layer](./0010-keyvault-encryption-layer.md) - Defines an in-memory keyvault boundary for lock state, key lifecycle, and encryption orchestration between domain logic and SQL persistence. From f7c77bf92089033502f33df376fd0427672a14bf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:18 +0800 Subject: [PATCH 130/695] wallet: add runtime wallet ID accessor --- wallet/manager_test.go | 9 ++++++ wallet/wallet.go | 66 +++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 3ae2131039..91f75bd9ae 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -10,6 +10,14 @@ import ( "github.com/stretchr/testify/require" ) +func TestWalletID(t *testing.T) { + t.Parallel() + + w := &Wallet{id: 42} + + require.Equal(t, uint32(42), w.ID()) +} + // TestManagerCreateSuccess verifies that a wallet can be successfully created // in various modes. It checks that the Manager correctly initializes the // wallet structure and registers it for tracking. @@ -465,6 +473,7 @@ func TestManagerLoadError(t *testing.T) { require.Error(t, err) require.Nil(t, w) }) + } // TestManagerString verifies that the String representation of the Manager diff --git a/wallet/wallet.go b/wallet/wallet.go index 03f4b2e168..b735377e5e 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -360,6 +360,9 @@ type Wallet struct { // wallet was created or loaded. cfg Config + // id is the persistent database identifier assigned to this wallet. + id uint32 + // sync is the dedicated synchronization component that manages the // chain loop, scanning, and reorganization handling. sync chainSyncer @@ -399,35 +402,9 @@ type Wallet struct { birthdayBlock waddrmgr.BlockStamp } -// hasMinConfs checks whether a transaction at height txHeight has met minconf -// confirmations for a blockchain at height curHeight. -func hasMinConfs(minconf uint32, txHeight, curHeight int32) bool { - confs := calcConf(txHeight, curHeight) - if confs < 0 { - return false - } - - return uint32(confs) >= minconf -} - -// calcConf returns the number of confirmations for a transaction given its -// containing block height and the current best block height. Unconfirmed -// transactions have a height of -1 and are considered to have 0 confirmations. -func calcConf(txHeight, curHeight int32) int32 { - switch { - // Unconfirmed transactions have 0 confirmations. - case txHeight == -1: - return 0 - - // A transaction in a block after the current best block is considered - // unconfirmed. This can happen during a chain reorg. - case txHeight > curHeight: - return 0 - - // Confirmed transactions have at least one confirmation. - default: - return curHeight - txHeight + 1 - } +// ID returns the persistent database identifier for the wallet. +func (w *Wallet) ID() uint32 { + return w.id } // RemoveDescendants attempts to remove any transaction from the wallet's tx @@ -584,3 +561,34 @@ func create(db walletdb.DB, pubPass, privPass []byte, return nil }) } + +// hasMinConfs checks whether a transaction at height txHeight has met minconf +// confirmations for a blockchain at height curHeight. +func hasMinConfs(minconf uint32, txHeight, curHeight int32) bool { + confs := calcConf(txHeight, curHeight) + if confs < 0 { + return false + } + + return uint32(confs) >= minconf +} + +// calcConf returns the number of confirmations for a transaction given its +// containing block height and the current best block height. Unconfirmed +// transactions have a height of -1 and are considered to have 0 confirmations. +func calcConf(txHeight, curHeight int32) int32 { + switch { + // Unconfirmed transactions have 0 confirmations. + case txHeight == -1: + return 0 + + // A transaction in a block after the current best block is considered + // unconfirmed. This can happen during a chain reorg. + case txHeight > curHeight: + return 0 + + // Confirmed transactions have at least one confirmation. + default: + return curHeight - txHeight + 1 + } +} From e3aba77f61f05d9ec186894f332e0afedc00c79f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:38 +0800 Subject: [PATCH 131/695] wallet: plumb runtime wallet ID through load --- wallet/manager.go | 6 ++++++ wallet/manager_test.go | 2 ++ 2 files changed, 8 insertions(+) diff --git a/wallet/manager.go b/wallet/manager.go index 99b42e98a8..6dcc6461e1 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -277,6 +277,11 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return nil, err } + // TODO(yy): Once the Store implementation is finalized, load the wallet + // ID from store.GetWallet instead of using the legacy single-wallet + // default. + walletID := uint32(0) + // Apply the safe default for auto-lock duration if not specified. if cfg.AutoLockDuration == 0 { cfg.AutoLockDuration = defaultLockDuration @@ -294,6 +299,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { w := &Wallet{ cfg: cfg, + id: walletID, addrStore: addrMgr, store: kvdb.NewStore(cfg.DB, txMgr), txStore: txMgr, diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 91f75bd9ae..d3c0c60578 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -124,6 +124,7 @@ func TestManagerCreateSuccess(t *testing.T) { // without error. require.NoError(t, err) require.NotNil(t, w) + require.Zero(t, w.ID()) // Verify internal state: Ensure the manager is tracking the // newly created wallet in its internal map, keyed by the @@ -394,6 +395,7 @@ func TestManagerLoadSuccess(t *testing.T) { m2.RUnlock() require.True(t, ok) require.Same(t, w, loadedW) + require.Zero(t, w.ID()) } // TestManagerLoad_ExistingWallet verifies that if Load is called for a wallet From 0aed3ea651884548a1b78974dd7f9d5ddd4ec368 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:43 +0800 Subject: [PATCH 132/695] wallet: use runtime wallet ID in output release --- wallet/common_test.go | 11 +++++++++++ wallet/manager.go | 6 +++--- wallet/manager_test.go | 2 +- wallet/utxo_manager.go | 4 +--- wallet/utxo_manager_test.go | 4 ++-- wallet/wallet.go | 7 +++++-- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index 578048fc1d..ff753bbce7 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -180,7 +180,18 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { func createStartedWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Helper() + return createStartedWalletWithID(t, 0) +} + +// createStartedWalletWithID creates a fully started Wallet instance whose +// runtime wallet ID is set before startup. +func createStartedWalletWithID(t *testing.T, walletID uint32) (*Wallet, + *mockWalletDeps) { + + t.Helper() + w, deps := createTestWalletWithMocks(t) + w.id = walletID // Mock the birthday block to be present. deps.addrStore.On("BirthdayBlock", mock.Anything). diff --git a/wallet/manager.go b/wallet/manager.go index 6dcc6461e1..8736928ebd 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -277,9 +277,9 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return nil, err } - // TODO(yy): Once the Store implementation is finalized, load the wallet - // ID from store.GetWallet instead of using the legacy single-wallet - // default. + // TODO(yy): Once Wallet.store includes wallet metadata accessors such as + // WalletStore.GetWallet, load the runtime wallet ID from that store + // instead of using the legacy single-wallet default. walletID := uint32(0) // Apply the safe default for auto-lock duration if not specified. diff --git a/wallet/manager_test.go b/wallet/manager_test.go index d3c0c60578..d1867ff775 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" ) +// TestWalletID verifies that Wallet.ID returns the cached runtime ID. func TestWalletID(t *testing.T) { t.Parallel() @@ -475,7 +476,6 @@ func TestManagerLoadError(t *testing.T) { require.Error(t, err) require.Nil(t, w) }) - } // TestManagerString verifies that the String representation of the Manager diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 3b7ec36cc0..4d1aa4e661 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -464,9 +464,7 @@ func (w *Wallet) ReleaseOutput(ctx context.Context, id wtxmgr.LockID, } params := db.ReleaseOutputParams{ - // TODO(yy): When multi-wallet support lands, plumb wallet ID into db - // calls instead of hard-coding 0. - WalletID: 0, + WalletID: w.id, ID: [32]byte(id), OutPoint: op, } diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 31f5b7da19..7fdabd241e 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -312,7 +312,7 @@ func TestReleaseOutput(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := createStartedWalletWithMocks(t) + w, mocks := createStartedWalletWithID(t, 7) // Create a UTXO. utxo := wire.OutPoint{ @@ -322,7 +322,7 @@ func TestReleaseOutput(t *testing.T) { // Mock the UTXOStore ReleaseOutput method to return nil. mocks.store.On("ReleaseOutput", mock.Anything, db.ReleaseOutputParams{ - WalletID: 0, + WalletID: 7, ID: [32]byte{1}, OutPoint: utxo, }).Return(nil) diff --git a/wallet/wallet.go b/wallet/wallet.go index b735377e5e..afe1617635 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -360,7 +360,10 @@ type Wallet struct { // wallet was created or loaded. cfg Config - // id is the persistent database identifier assigned to this wallet. + // id is the runtime wallet identifier used by wallet-scoped DB calls. + // + // NOTE: Until the wallet store is wired into the manager load path, this + // field may remain the legacy zero-value for single-wallet setups. id uint32 // sync is the dedicated synchronization component that manages the @@ -402,7 +405,7 @@ type Wallet struct { birthdayBlock waddrmgr.BlockStamp } -// ID returns the persistent database identifier for the wallet. +// ID returns the runtime wallet identifier for the wallet. func (w *Wallet) ID() uint32 { return w.id } From 0ad756c364381a5a5962446a52188dad693e04ac Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 13 Mar 2026 22:38:37 +0800 Subject: [PATCH 133/695] build: fix tidy-module script Tidy the root and each real nested module while continuing across modules and failing at the end if any tidy run errors. This prevents silent submodule failures and avoids collapsing nested modules under a single parent path. --- scripts/tidy_modules.sh | 61 ++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/scripts/tidy_modules.sh b/scripts/tidy_modules.sh index 3fa5bfb252..4dc7e0d738 100755 --- a/scripts/tidy_modules.sh +++ b/scripts/tidy_modules.sh @@ -1,17 +1,58 @@ #!/bin/bash -SUBMODULES=$(find . -mindepth 2 -name "go.mod" | cut -d'/' -f2) +# Keep unset-variable and pipeline safety, but do NOT use `set -e` here. +# +# We want two properties at the same time: +# 1. `go mod tidy` should run for the root module and every nested module, so we +# still auto-fix any module that *can* be tidied in the current run. +# 2. A failure in one module must still make the overall script fail so CI does +# not silently pass over a broken submodule. +# +# Using `set -e` would stop on the first failing module and skip the remaining +# tidies. Instead, we run each module explicitly, collect failures, and exit +# non-zero at the end if any module failed. +set -uo pipefail +# Collect module directories whose `go mod tidy` invocation failed. +failures=() -# Run 'go mod tidy' for root. -go mod tidy +run_tidy() { + local module_dir="$1" -# Run 'go mod tidy' for each module. -for submodule in $SUBMODULES -do - pushd $submodule + # Run each tidy in the target module directory so the command behaves as if a + # developer had entered that module and run `go mod tidy` manually. + echo "Running 'go mod tidy' in ${module_dir}" - go mod tidy + if ! ( + # Turn off any parent go.work file. We want to validate each module as an + # independent module, because CI and release consumers will resolve module + # dependencies that way. + cd "$module_dir" + GOWORK=off go mod tidy + ); then + # Keep going so other modules still get tidied, but remember the failure so + # the script can fail loudly once every module has been attempted. + failures+=("$module_dir") + fi +} - popd -done +# Tidy the repository root module first. +run_tidy "." + +# Tidy every actual nested Go module. +# +# We intentionally discover module directories from real `go.mod` paths instead +# of truncating the path (for example, `wallet/txauthor`, `wallet/txrules`, and +# `wallet/txsizes` are distinct modules and must be tidied separately). +while IFS= read -r submodule; do + run_tidy "$submodule" +done < <(find . -mindepth 2 -name "go.mod" -exec dirname {} \; | sort -u) + +# Fail at the end if any module failed to tidy. This preserves the previous +# “tidy as much as possible” behavior while making module errors visible to CI. +if [ ${#failures[@]} -ne 0 ]; then + echo + echo "go mod tidy failed for the following modules:" + printf ' - %s\n' "${failures[@]}" + exit 1 +fi From 4b40ee32d10596297b540c6bb675447938232e96 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 03:09:52 +0800 Subject: [PATCH 134/695] multi: fix wtxmgr module tidy check `make tidy-module` runs `go mod tidy` in the repository root and in each nested Go module, including `./wtxmgr`. Since `wtxmgr` has its own `go.mod`, it must be tidy-able as an independent module. Before this change, `wtxmgr/log.go` imported `github.com/btcsuite/btcwallet/build`, which lives in the root module rather than the `wtxmgr` module. That creates a cross-module dependency: when `go mod tidy` runs inside `./wtxmgr`, it tries to resolve `github.com/btcsuite/btcwallet/build` as if it were available from the published parent module, and the tidy step fails. The logger initialization did not actually require the root `build` package here. The previous code called `build.NewSubLogger("TMGR", nil)`, and with a nil sublogger factory that helper falls back to `btclog.Disabled`. Initializing `wtxmgr` directly with `btclog.Disabled` therefore preserves the same default behavior: logging remains disabled until a parent package installs a logger explicitly via `wtxmgr.UseLogger`. This keeps `wtxmgr` self-contained for module tidy/build checks while preserving the package's runtime logging behavior. --- wtxmgr/log.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/wtxmgr/log.go b/wtxmgr/log.go index 9a2f0adfe9..ecb5a87925 100644 --- a/wtxmgr/log.go +++ b/wtxmgr/log.go @@ -4,20 +4,12 @@ package wtxmgr -import ( - "github.com/btcsuite/btclog" - "github.com/btcsuite/btcwallet/build" -) - -// log is a logger that is initialized with no output filters. This -// means the package will not perform any logging by default until the caller -// requests it. -var log btclog.Logger - -// The default amount of logging is none. -func init() { - UseLogger(build.NewSubLogger("TMGR", nil)) -} +import "github.com/btcsuite/btclog" + +// log is a logger that is initialized as disabled (no output). This means +// the package will not perform any logging by default until the caller +// configures a logger via UseLogger or SetLogWriter. +var log = btclog.Disabled // DisableLog disables all library log output. Logging output is disabled // by default until either UseLogger or SetLogWriter are called. From 05794c395e0212f59355073ac190f8c59a91ec6f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 03:09:51 +0800 Subject: [PATCH 135/695] wallet: fix flaky account itest timestamps --- .../internal/db/itest/account_store_test.go | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 98b39be250..0f8be143c9 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -499,11 +499,18 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { scope := db.KeyScopeBIP0084 + type createdAccount struct { + info db.AccountInfo + createdNear time.Time + } + // Create three accounts with slight delays to ensure different // timestamps. - var accounts []db.AccountInfo + var accounts []createdAccount for i := range 3 { time.Sleep(1 * time.Second) + + createdNear := time.Now() params := db.CreateDerivedAccountParams{ WalletID: walletID, Scope: scope, @@ -511,21 +518,24 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { } info, err := store.CreateDerivedAccount(t.Context(), params) require.NoError(t, err) - accounts = append(accounts, *info) + accounts = append(accounts, createdAccount{ + info: *info, + createdNear: createdNear, + }) } // Verify all accounts have CreatedAt populated. for i, acc := range accounts { - require.False(t, acc.CreatedAt.IsZero(), + require.False(t, acc.info.CreatedAt.IsZero(), "account %d should have CreatedAt set", i) - require.WithinDuration(t, time.Now(), acc.CreatedAt, 5*time.Second, - "account %d CreatedAt should be recent", i) + require.WithinDuration(t, acc.createdNear, acc.info.CreatedAt, + 5*time.Second, "account %d CreatedAt should track creation", i) } // Verify accounts are ordered by creation time. - require.True(t, accounts[0].CreatedAt.Before(accounts[1].CreatedAt), + require.True(t, accounts[0].info.CreatedAt.Before(accounts[1].info.CreatedAt), "account 0 should have CreatedAt before account 1") - require.True(t, accounts[1].CreatedAt.Before(accounts[2].CreatedAt), + require.True(t, accounts[1].info.CreatedAt.Before(accounts[2].info.CreatedAt), "account 1 should have CreatedAt before account 2") } @@ -800,10 +810,13 @@ func requireAccountMatches(t *testing.T, info *db.AccountInfo, require.Equal(t, tc.Origin, info.Origin) require.Equal(t, tc.IsWatchOnly, info.IsWatchOnly) - // Verify CreatedAt is populated and recent. + // Verify CreatedAt is populated and not in the future. The account may have + // been created several seconds earlier in the test when parallel database + // setup runs under the race detector, so a strict "recent" assertion here is + // unnecessarily flaky. require.False(t, info.CreatedAt.IsZero(), "CreatedAt should be set") - require.WithinDuration(t, time.Now(), info.CreatedAt, 5*time.Second, - "CreatedAt should be recent") + require.False(t, info.CreatedAt.After(time.Now().Add(5*time.Second)), + "CreatedAt should not be in the future") } // requireAccountPropertiesMatches asserts that the provided AccountProperties @@ -819,10 +832,12 @@ func requireAccountPropertiesMatches(t *testing.T, props *db.AccountProperties, require.Equal(t, tc.Origin, props.Origin) require.Equal(t, tc.IsWatchOnly, props.IsWatchOnly) - // Verify CreatedAt is populated and recent. + // Verify CreatedAt is populated and not in the future. Imported-account test + // fixtures can be created well before these assertions run under heavy CI + // contention, so only the forward-time invariant is stable here. require.False(t, props.CreatedAt.IsZero(), "CreatedAt should be set") - require.WithinDuration(t, time.Now(), props.CreatedAt, 5*time.Second, - "CreatedAt should be recent") + require.False(t, props.CreatedAt.After(time.Now().Add(5*time.Second)), + "CreatedAt should not be in the future") } // findAccountInList searches for an account in the provided list that matches From 83efbae569e53931584933d15da7ef1c5f6c970b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 22:42:23 +0800 Subject: [PATCH 136/695] wallet: add `_time_format` for sqlite SQLite does not have a dedicated native timestamp type, so consistent timestamp behavior depends on how the driver serializes Go `time.Time` values. The SQL wallet schema already mixes: - application-supplied `time.Time` values (for example birthday timestamps), - and SQLite-generated timestamp values such as `current_timestamp` for metadata fields like `created_at` and `updated_at`. Set `_time_format=sqlite` on the sqlite DSN so the driver encodes and decodes `time.Time` using SQLite's expected timestamp layout. This keeps round-tripping predictable and makes app-written timestamps consistent with SQLite-generated timestamp values across the schema. --- wallet/internal/db/sqlite.go | 1 + 1 file changed, 1 insertion(+) diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index b6af1a94d1..53e582f761 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -31,6 +31,7 @@ func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, dsn += "&_pragma=journal_mode=WAL" dsn += "&_txlock=immediate" dsn += "&_pragma=busy_timeout=5000" + dsn += "&_time_format=sqlite" db, err := sql.Open("sqlite", dsn) if err != nil { From bd2c79109c33599f87e2be3ddec53963fd7fa04f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 03:22:00 +0800 Subject: [PATCH 137/695] wallet: fix postgres itest container readiness Postgres 18 can start listening on 5432 before it is ready to accept SQL queries, which makes the shared postgres itest container race with CREATE DATABASE during CI. Wait for a successful SQL round trip instead of a listening port so the postgres integration tests do not fail with connection resets or unexpected EOFs during setup. --- wallet/internal/db/itest/pg_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index d8384d806a..d8975fa329 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -96,13 +97,26 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, err pgContainerOnce.Do(func() { cfg := DefaultPostgresConfig() - pgContainer, pgContainerErr = postgres.RunContainer(ctx, - testcontainers.WithImage(cfg.Image), + // PostgreSQL 18 can begin listening on the TCP port before it is + // ready to handle client queries, so wait for a successful SQL round + // trip instead of only waiting for the port to open. + waitForSQL := wait.ForSQL( + "5432/tcp", "pgx", func(host string, port nat.Port) string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=disable", + cfg.Username, cfg.Password, host, port.Port(), + cfg.Database, + ) + }, + ).WithStartupTimeout(pgInitTimeout) + + pgContainer, pgContainerErr = postgres.Run(ctx, + cfg.Image, postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), testcontainers.WithWaitStrategyAndDeadline( - pgInitTimeout, wait.ForListeningPort("5432/tcp"), + pgInitTimeout, waitForSQL, ), ) }) From 1c02d909384e0ab8cea589bda7235f51580981f9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 03:30:04 +0800 Subject: [PATCH 138/695] build: add consolidated sql make target Working on SQL migrations and queries currently requires separate sql-lint, sql-format, and sqlc invocations, and the linting steps can rewrite SQL after code generation. Add make sql to run linting, formatting, and generation in one place so local development normalizes the SQL first and only regenerates the Go files once from the final contents. --- Makefile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Makefile b/Makefile index 5e6fe85144..f8ebe01f54 100644 --- a/Makefile +++ b/Makefile @@ -286,6 +286,20 @@ sql-lint-check: @$(call print, "Linting SQL files (sqlite queries).") $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) +#? sql: Lint, verify, format, and regenerate SQL code for local development +# First try auto-fixes. If that still fails, rerun lint in check mode so the +# remaining unfixable violations are printed before aborting the workflow. +# On success, verify the final SQL is clean before formatting and regeneration. +sql: + @if ! $(MAKE) --no-print-directory --silent sql-lint; then \ + $(MAKE) --no-print-directory --silent sql-lint-check; \ + exit 1; \ + fi + @$(MAKE) --no-print-directory --silent sql-lint-check + @$(MAKE) --no-print-directory --silent sql-format + @$(MAKE) --no-print-directory --silent sqlc + + .PHONY: all \ default \ build \ @@ -303,6 +317,7 @@ sql-lint-check: tidy-module \ tidy-module-check \ sql-parse \ + sql \ sqlc \ sqlc-check \ sql-format \ From 1fab2b36e0bff55067e0a7170c848df5c54345b6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:25 +0800 Subject: [PATCH 139/695] docs: add ADR 0006 implementation notes --- docs/developer/adr/0006-wtxmgr-sql-schema.md | 455 +++++++------------ docs/developer/utxo_data_model.md | 64 ++- 2 files changed, 195 insertions(+), 324 deletions(-) diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md index 55778aca79..9fbf0c924b 100644 --- a/docs/developer/adr/0006-wtxmgr-sql-schema.md +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -18,9 +18,9 @@ We will adopt a **UTXO-Centered, Soft-Deletion Schema**. ### 2.1 Core Principles 1. **UTXO-Centered Operations:** The schema is optimized for `Balance()` and `CoinSelection()` queries, which focus on the `utxos` table. -2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`status='published'`) or is explicitly allowed for chaining (`status='pending'`). +2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`tx_status = 1`, `published`) or is explicitly allowed for chaining (`tx_status = 0`, `pending`). 3. **Immutable History (Soft Deletion):** We **NEVER** automatically `DELETE` rows. - * Failed/RBF'd transactions are marked with a `status` field (e.g., `replaced`, `failed`). + * Failed/RBF'd transactions are marked with a `tx_status` field (e.g., `replaced`, `failed`). * They remain in the database for audit history but are excluded from balance queries. * Foreign Keys use `ON DELETE RESTRICT` for creation relationships to prevent accidental data loss. 4. **Wallet-Scoped Rows:** All `wtxmgr` tables are scoped by `wallet_id` to support multiple wallets sharing a single database without row-level conflicts. @@ -40,19 +40,21 @@ This ADR chooses wallet-scoped tables for simplicity and implementability. A fut ### 2.1.2 Explicit Status vs. Derived Status Two designs were considered for tracking transaction validity: -* **Explicit `status` column (chosen):** - * The `status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). - * Pros: Balance and coin-selection queries filter on a single status predicate (`status IN ('published', 'pending')`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. +* **Explicit `tx_status` column (chosen):** + * The `tx_status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). + * It is stored as a compact numeric code (`0 = pending`, `1 = published`, `2 = replaced`, `3 = failed`, `4 = orphaned`) so hot-path predicates and indexes do not pay the storage or comparison cost of repeated status strings. + * The schema intentionally does **not** use a separate status lookup table. The status set is tiny, closed, and application-owned, so a reference table would add foreign-key and seed-data complexity without adding meaningful flexibility. Keeping the code inline on the transaction row preserves hot-path simplicity, while the Go enum layer provides the human-readable names. + * Pros: Balance and coin-selection queries filter on a single status predicate (`tx_status IN (1, 0)`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. * **Derived status from other columns (alternative):** * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. - * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `status` column. + * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `tx_status` column. * Cascading invalidation adds further complexity: downstream txs whose parent became invalid were never directly "replaced." Without a pre-computed status, every balance/coin-selection query would need a recursive CTE walking up the ancestor chain — O(depth) per tx on the hot path. -This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. +This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `tx_status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. ### 2.2 Consistency & Concurrency Model This design assumes standard SQL ACID guarantees. @@ -71,316 +73,169 @@ Recommended operational defaults: * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (they cannot exist outside the block that created them). * **Atomicity requirement:** The coinbase status update MUST occur atomically with the disconnect. * PostgreSQL evaluates `CHECK` constraints immediately, including updates performed by foreign-key actions such as `ON DELETE SET NULL`. - * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `status` is updated to `orphaned` as part of the same statement. - * Recommended: use a trigger to rewrite coinbase `status` during the `block_height -> NULL` update (see 3.6). - * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `status='published'` atomically. + * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `tx_status` is updated to `orphaned` as part of the same statement. + * Recommended: use a trigger to rewrite coinbase `tx_status` during the `block_height -> NULL` update (see 3.5). + * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `tx_status = 1` (`published`) atomically. * **RBF:** Handled by updating the `utxos.spent_by_tx_id` pointer to the new transaction and marking the old transaction as `replaced`. -## 3. Reference Schema - -### 3.1 Wallet: `transactions` -Stores the provenance of funds for a specific wallet. Acts as the source of truth for Validity (`status`) and Confirmation (`block_height`). - -```sql -CREATE TABLE transactions ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), - -- Raw transaction bytes. This is typically TOASTed in PostgreSQL. - -- Hot-path queries (balance/coin selection) SHOULD avoid selecting this column. - raw_tx BYTEA NOT NULL, - - -- Confirmation State: - -- NULL = Unconfirmed (Mempool) - -- INT = Confirmed (Mined) - -- ON DELETE SET NULL: If block is reorged, tx becomes unconfirmed. - block_height INTEGER REFERENCES blocks(block_height) ON DELETE SET NULL, - - -- Validity State (Soft Deletion): - -- pending: Created locally, not yet broadcast. - -- published: Active in mempool or blockchain (Valid); not necessarily broadcast by this wallet. - -- replaced: RBF'd by another transaction (Invalid). - -- failed: Double-spent by a competitor (Invalid). - -- orphaned: Coinbase tx that was reorged out (Invalid). - status VARCHAR(20) NOT NULL DEFAULT 'pending', - - -- Absolute wall clock time, stored in UTC. - received_time TIMESTAMPTZ NOT NULL, - is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, - - -- Composite primary key is intentional: it allows foreign keys to enforce - -- wallet scoping (preventing cross-wallet references). Do not replace this - -- with a single-column PK on `id` unless all referencing FKs are also - -- reworked to maintain the same invariant. - CONSTRAINT pidx_transactions PRIMARY KEY (wallet_id, id), - CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), - CONSTRAINT valid_status CHECK (status IN ('pending', 'published', 'replaced', 'failed', 'orphaned')), - -- Invariant: If a transaction is confirmed (mined), it must be 'published'. - CONSTRAINT check_confirmed_published CHECK ( - block_height IS NULL OR status = 'published' - ), - -- Invariant: Coinbase transactions cannot exist in the mempool. - -- If a coinbase transaction loses its block via a reorg, it becomes orphaned. - CONSTRAINT check_coinbase_not_pending CHECK (NOT (is_coinbase AND status = 'pending')), - CONSTRAINT check_coinbase_confirmation_state CHECK ( - NOT is_coinbase OR - (block_height IS NOT NULL AND status = 'published') OR - (block_height IS NULL AND status = 'orphaned') - ) -); - --- Optimization for Mempool lookups -CREATE INDEX idx_transactions_unconfirmed -ON transactions (wallet_id, block_height) -WHERE block_height IS NULL; - --- Optimization for "all transactions in block X" queries -CREATE INDEX idx_transactions_by_block -ON transactions (wallet_id, block_height) -WHERE block_height IS NOT NULL; - --- Optimization for "latest transactions" queries -CREATE INDEX idx_transactions_by_received_time -ON transactions (wallet_id, received_time DESC); - --- Optimization for status-based filtering (non-hot path) --- Optional: profile before enabling (low cardinality) --- CREATE INDEX idx_transactions_by_status --- ON transactions (wallet_id, status); -``` - -### 3.2 Local: `utxos` -The **Single Source of Truth** for wallet balance. -Note: The table keeps the `utxos` name for consistency with wallet abstractions, even though it retains spent rows for audit history (`spent_by_tx_id` marks spent outputs). - -```sql -CREATE TABLE utxos ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - - -- Creation (OutPoint): - -- ON DELETE RESTRICT: We NEVER delete confirmed transactions or their UTXOs. - -- To remove history, the user must explicitly Prune (manual op). - tx_id BIGINT NOT NULL, - output_index INTEGER NOT NULL CHECK (output_index >= 0), - - amount BIGINT NOT NULL CHECK (amount >= 0), - -- The output script is stored on the address record (see the address-manager - -- schema). `addresses.script_pub_key` is expected to be NOT NULL for all - -- addresses, including HD-derived addresses. - -- Optional address book reference for indexing and UX. - -- The `addresses` table is part of the address-manager schema (tracked - -- separately from this ADR's `wtxmgr` schema). - -- - -- Note: in the draft address schema (`wallet/internal/db/migrations/*/000006_addresses.up.sql`), - -- `addresses` is keyed by an `account_id` that is ultimately rooted in a - -- specific wallet (via key scopes). Implementations must ensure `address_id` - -- always refers to an address belonging to the same `wallet_id`. - address_id BIGINT NOT NULL REFERENCES addresses(id) ON DELETE RESTRICT, - - -- Spending (Input): - -- ON DELETE SET NULL: If the spending tx is manually pruned, the UTXO becomes unspent. - spent_by_tx_id BIGINT, - -- NULL when unspent; non-NULL when spent (enforced by pair constraint). - spent_input_index INTEGER CHECK (spent_input_index IS NULL OR spent_input_index >= 0), - - -- Composite primary key is intentional: it allows leases and other - -- references to enforce wallet scoping. - CONSTRAINT pidx_utxos PRIMARY KEY (wallet_id, id), - CONSTRAINT fkey_utxos_tx FOREIGN KEY (wallet_id, tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE RESTRICT, - CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (wallet_id, spent_by_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE SET NULL, - - CONSTRAINT check_spent_tx_and_index_pair CHECK ( - (spent_by_tx_id IS NULL AND spent_input_index IS NULL) OR - (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) - ), - - CONSTRAINT uidx_utxos_outpoint UNIQUE (wallet_id, tx_id, output_index) -); - --- Optimization for Balance Queries (Index-Only Scan) -CREATE INDEX idx_utxos_unspent ON utxos (address_id, amount) WHERE spent_by_tx_id IS NULL; - --- Optimization for listing all UTXOs for an address (including spent) -CREATE INDEX idx_utxos_by_address ON utxos (address_id); - --- Optimization for finding inputs (debits) of a transaction -CREATE INDEX idx_utxos_spent_by ON utxos(wallet_id, spent_by_tx_id); - --- Optimization for listing all outputs of a transaction -CREATE INDEX idx_utxos_by_tx ON utxos(wallet_id, tx_id); -``` - -Denormalization note: -* This schema is normalized: the canonical locking script is stored in the address book (`addresses.script_pub_key`). -* Joining through `address_id` adds work to the signing/reconstruction path, but keeps the hot UTXO table small and avoids duplicating script data. - -Cross-wallet integrity note: -* Ideally, the database should prevent a `utxos` row from referencing an `address_id` belonging to a different wallet. -* The strongest enforcement is a composite FK `FOREIGN KEY (wallet_id, address_id) REFERENCES addresses(wallet_id, id)`. -* If the address-manager schema does not expose `wallet_id` on `addresses`, this invariant must be enforced by application logic. - -### 3.3 Audit: `tx_replacements` -Tracks the history of RBF and Double-Spends. - -```sql -CREATE TABLE tx_replacements ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - - -- ON DELETE CASCADE: Supports Manual Pruning. - -- If a transaction is physically deleted (to save space), - -- its audit history is automatically cleaned up to prevent FK violations. - replaced_tx_id BIGINT NOT NULL, - replacement_tx_id BIGINT NOT NULL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Composite primary key is intentional: it enforces wallet scoping. - CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), - CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY (wallet_id, replaced_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, - CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY (wallet_id, replacement_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, - CONSTRAINT check_not_self_replacement CHECK (replaced_tx_id != replacement_tx_id), - CONSTRAINT uidx_tx_replacements_edge UNIQUE (wallet_id, replaced_tx_id, replacement_tx_id) -); -``` - -### 3.4 Local: `utxo_leases` -Transient application locks. - -```sql -CREATE TABLE utxo_leases ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - utxo_id BIGINT NOT NULL, - external_lock_id BYTEA NOT NULL CHECK (length(external_lock_id) = 32), - expires_at TIMESTAMPTZ NOT NULL, - - -- Composite primary key is intentional: it enforces wallet scoping. - CONSTRAINT pidx_utxo_leases PRIMARY KEY (wallet_id, utxo_id), - CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (wallet_id, utxo_id) - REFERENCES utxos(wallet_id, id) ON DELETE CASCADE -); - --- Optimization for lease cleanup -CREATE INDEX idx_utxo_leases_expires_at ON utxo_leases(expires_at); - --- Lease cleanup is expected to be periodic: --- DELETE FROM utxo_leases WHERE expires_at <= CURRENT_TIMESTAMP; - --- Lease acquisition is expected to be a single atomic statement. --- Recommended pattern (PostgreSQL): acquire if absent, renew if same external_lock_id, --- or steal only if expired. --- Portability note: SQLite supports UPSERT, but uses different time functions. --- Prefer `CURRENT_TIMESTAMP` in cross-database examples. --- The `WHERE` clause in `ON CONFLICT DO UPDATE` is supported in SQLite 3.24.0+; verify compatibility with your target version. --- --- INSERT INTO utxo_leases (wallet_id, utxo_id, external_lock_id, expires_at) --- VALUES ($1, $2, $3, CURRENT_TIMESTAMP + $4) --- ON CONFLICT (wallet_id, utxo_id) DO UPDATE --- SET external_lock_id = EXCLUDED.external_lock_id, --- expires_at = EXCLUDED.expires_at --- WHERE utxo_leases.expires_at <= CURRENT_TIMESTAMP --- OR utxo_leases.external_lock_id = EXCLUDED.external_lock_id --- RETURNING expires_at; --- --- If no row is returned, the UTXO is currently leased by another external_lock_id. - -Deadlock avoidance note: -* When acquiring multiple leases in one transaction, acquire them in a stable order - (for example sorted by `(wallet_id, utxo_id)`) to reduce deadlock risk. -``` - -### 3.5 Convenience Views - -**`spendable_utxos` View:** -Encapsulates the logic of joining transactions to filter out invalid/failed parents. -Note: Filtering for maturity (Coinbase > 100 confs) is done at the application layer using the exposed `block_height` and `is_coinbase` columns, as Views cannot accept dynamic parameters like `current_height`. - -Suggested helper (parameterized query): -* Expose a query helper that takes `current_height` and filters out immature coinbase outputs: - * `WHERE (NOT is_coinbase) OR (current_height - block_height) >= 100` - -Important: The view includes `pending` parent transactions to enable zero-latency chaining. This is an advanced mode and should be opt-in for conservative spending policies. - -Note: Leases are time-based and depend on the database's current time function. For clarity, the view does not attempt to exclude leased UTXOs. Coin selection MUST exclude active leases (for example using a `NOT EXISTS` subquery against `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`). - -```sql -CREATE VIEW spendable_utxos AS -SELECT - u.*, - t.block_height, - t.is_coinbase, - t.status as tx_status -FROM utxos u -JOIN transactions t ON t.wallet_id = u.wallet_id AND t.id = u.tx_id -WHERE u.spent_by_tx_id IS NULL - AND t.status IN ('published', 'pending'); -``` - -### 3.6 Triggers (PostgreSQL) - -To enforce the coinbase invariant under `ON DELETE SET NULL`, PostgreSQL needs a trigger because `CHECK` constraints are not deferrable. - -Recommended behavior: -* When `block_height` transitions from `NOT NULL` to `NULL`, and `is_coinbase = TRUE`, automatically rewrite `status = 'orphaned'`. - -This can be implemented with a `BEFORE UPDATE` trigger on `transactions`. Foreign-key actions execute as ordinary `UPDATE` statements, and triggers on the referencing table will fire. - -Portability note: -* PostgreSQL: Foreign-key actions (such as `ON DELETE SET NULL`) are performed via ordinary `UPDATE` statements on the referencing table, and triggers on that table will fire. -* SQLite: Foreign-key actions occur after the parent row operation. If you cannot rely on triggers to rewrite `status` during the FK action, enforce coinbase orphaning with an explicit application-side update in the same SQL transaction as the disconnect. - -**SQLite example:** To atomically disconnect a block and orphan its coinbase transactions, run the following within a single transaction: -```sql -BEGIN; -DELETE FROM blocks WHERE block_height = ?; -UPDATE transactions SET status = 'orphaned' WHERE is_coinbase AND block_height IS NULL; -COMMIT; -``` - -Example sketch: - -```sql -CREATE FUNCTION set_coinbase_orphaned_on_disconnect() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL AND NEW.is_coinbase THEN - NEW.status := 'orphaned'; - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect -BEFORE UPDATE OF block_height ON transactions -FOR EACH ROW -EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); -``` +### 2.4 Implementation Notes + +This ADR includes a reference schema. The implementation keeps the same +invariants, but makes a few deliberate schema choices to match the existing +conventions in `wallet/internal/db/migrations/`. + +**Primary keys and wallet scoping** + +The reference schema uses composite primary keys (`(wallet_id, id)`) to make +wallet scoping enforceable with foreign keys. + +In this repository, SQLite tables follow the rowid-backed +`INTEGER PRIMARY KEY` pattern used by the existing wallet/account/address +schema. To preserve the wallet-scoping invariant while keeping that +convention, the implementation uses single-column primary keys on `id` and +adds `UNIQUE(wallet_id, id)` constraints on wallet-scoped tables. Child tables +then use composite foreign keys referencing `(wallet_id, id)`. + +**Manual pruning and `spent_by` semantics** + +The reference schema uses `ON DELETE SET NULL` for the +`(wallet_id, spent_by_tx_id)` foreign key so that physically deleting a +spending transaction can restore a UTXO to the unspent set. + +In a composite foreign key, `ON DELETE SET NULL` applies to *all* referencing +columns. With `utxos.wallet_id` being `NOT NULL`, the reference behavior cannot +be expressed directly as written. + +The implementation therefore uses `ON DELETE RESTRICT` for the spender foreign +key and defines manual pruning as an explicit, application-driven operation +that clears `utxos.spent_by_*` before deleting/pruning the spending +transaction, all within a single SQL transaction. + +**SQLite coinbase disconnect handling** + +PostgreSQL can rely on the transaction-row trigger alone when a block delete +causes `transactions.block_height` to become `NULL`. + +SQLite evaluates child-row checks before an `AFTER UPDATE` trigger on the child +table can normalize the row. To preserve the coinbase orphaning invariant, the +implementation adds a `BEFORE DELETE ON blocks` trigger that rewrites affected +transactions into their final disconnected state before the block row is +removed. + +**Transaction labels** + +User-facing labels are part of the internal store contract (`TxInfo.Label`). +The implementation stores them inline as `transactions.tx_label` instead of a +separate labels table to keep the hot read path simple. + +**Timestamps** + +Both PostgreSQL and SQLite now follow the same timestamp contract: + +- all persisted timestamps represent UTC instants +- PostgreSQL stores them as `TIMESTAMP` +- SQLite stores them as `TIMESTAMP`/`DATETIME` +- logic-sensitive comparisons (for example lease expiry) use caller-supplied UTC + values instead of relying on session-local database time semantics + +## 3. Implemented Schema Notes + +The SQL migrations under `wallet/internal/db/migrations/postgres` and +`wallet/internal/db/migrations/sqlite` are the source of truth for the concrete +schema. This section captures the important design decisions without duplicating +full reference DDL that can drift from the live migrations. + +### 3.1 Transactions + +- `transactions` remains wallet-scoped through `wallet_id` +- `tx_status` stores the wallet-relative validity state inline as a small, + closed numeric enum +- `tx_label` stores user-facing labels inline and keeps the hot read path simple +- `raw_tx` is retained because the schema does not store a fully normalized + input/output graph; callers still need to reconstruct `wire.MsgTx` for + transaction reads and dependency walks +- `UNIQUE (wallet_id, id)` remains so wallet-scoped child relations can use a + composite foreign-key target where needed + +### 3.2 UTXOs + +- `utxos.wallet_id` is intentionally not stored +- wallet ownership is derived from the creating transaction: + `utxos.tx_id -> transactions.wallet_id` +- the owning address is still recorded through `address_id` +- correctness requires the wallet derived from `tx_id` and the wallet derived + from `address_id` to match +- PostgreSQL and SQLite both enforce this invariant with triggers on `utxos` +- same-wallet spend edges are also enforced by trigger when `spent_by_tx_id` is + present + +This keeps the UTXO row normalized while preserving the important wallet-scoped +integrity checks. + +### 3.3 Replacement edges + +- `tx_replacements` remains wallet-scoped +- both endpoints reference wallet-scoped transaction rows through + `(wallet_id, id)` +- `created_at` is stored as a UTC `TIMESTAMP` + +### 3.4 UTXO leases + +- `utxo_leases` keeps `wallet_id` as a query helper for wallet-scoped lease + scans and cleanup +- the row is keyed by `utxo_id`, so one UTXO can have at most one lease row +- wallet consistency between `utxo_leases.wallet_id` and the leased UTXO is + enforced by trigger +- `expires_at` is stored as a UTC `TIMESTAMP` +- lease acquisition, renewal, and cleanup compare against explicit UTC values + supplied by the caller + +### 3.5 Triggers + +The implementation relies on triggers for the invariants that cannot be fully +expressed through ordinary foreign keys alone: + +- coinbase disconnect/orphan handling on `transactions` +- `wallet(tx_id) == wallet(address_id)` on `utxos` +- same-wallet `spent_by_tx_id` on `utxos` +- lease wallet consistency on `utxo_leases` + +This keeps the database responsible for protecting the critical wallet-scoping +rules rather than assuming the application layer is always correct. ## 4. Consequences ### 4.1. True Multi-Wallet Support -All `wtxmgr` tables are scoped by `wallet_id`. This allows multiple wallets to share the same database without conflicting unique constraints (for example, outpoints and transaction hashes are unique per wallet). +The transaction graph remains wallet-scoped, and every wallet-owned row is +either directly keyed by `wallet_id` or derives its wallet ownership through a +wallet-scoped parent transaction. This allows multiple wallets to share the +same database without conflicting unique constraints while still permitting the +same network outpoint or tx hash to appear in multiple wallets independently. Note: A separate, truly global `transactions` table shared across wallets is a different design. That approach would require a join table (e.g., `wallet_transactions`) to track per-wallet ownership and metadata. ### 4.2. Native SQL Efficiency -Balances are calculated using `SUM(amount)` on the `utxos` table (or `spendable_utxos` view), leveraging database optimizations. +Balances are calculated using `SUM(amount)` over the normalized `utxos` table +with transaction/account joins and database-side filtering, leveraging native +database optimizations. + +The schema intentionally stops short of declaring one canonical "spendable +balance" API because callers may disagree about pending chaining, lease +exclusion, confirmation thresholds, or coinbase maturity rules. ### 4.3. Audit Trail -By using "Soft Deletion" (`status='replaced'`), we maintain a complete history of user attempts, even those that failed. This is superior to previous designs that physically deleted failed transactions. +By using soft-deletion style transaction states (`tx_status = 2`, `replaced`), +we maintain a complete history of user attempts, even those that failed. This +is superior to previous designs that physically deleted failed transactions. ### 4.4. Complexity Trade-off We accept slightly more complexity in **Transaction Reconstruction** (joining inputs/outputs) in exchange for maximal performance in **Balance Calculation** and **Coin Selection**, which are the high-frequency operations. Additional operational consequences: -* **Pending-chaining is advanced:** The `spendable_utxos` view includes `pending` parents to enable zero-latency chaining. This increases operational risk (child transactions depend on parents being broadcast) and should be disabled for conservative policies. +* **Pending-chaining is advanced:** Zero-latency chaining should remain an + application-level choice. Callers that opt into `pending` parents take on + the operational risk that child transactions depend on parents being + broadcast successfully. * **Recursive invalidation is unbounded in theory:** Marking downstream transactions `failed` after an upstream double-spend can require recursive graph traversal. Implementations should assume bounded typical depth but plan for worst-case behavior (e.g., set a maximum recursion depth or iteration limit). ### 4.5 Operational Notes (Out of Scope for This ADR) diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md index ed077c2390..5e99026ef2 100644 --- a/docs/developer/utxo_data_model.md +++ b/docs/developer/utxo_data_model.md @@ -124,15 +124,20 @@ We distinguish between a transaction's **Validity** (Status) and its **Confirmat **2. Validity (Source of Truth: `Status`)** -| Status | Meaning | Affects Balance? | +| Status Code | Meaning | Affects Balance? | | :--- | :--- | :--- | -| `pending` | Created locally, not yet broadcast. | **No** (Locked) | -| `published` | Active in mempool or blockchain. | **Yes** (If valid) | -| `replaced` | Replaced by a higher-fee transaction (RBF). | **No** | -| `failed` | Invalidated by a conflicting transaction (Double-Spend). | **No** | -| `orphaned` | Coinbase tx that was reorged out (Invalid). | **No** | +| `0` (`pending`) | Created locally, not yet broadcast. | **Yes** (Factual) | +| `1` (`published`) | Active in mempool or blockchain. | **Yes** (If valid) | +| `2` (`replaced`) | Replaced by a higher-fee transaction (RBF). | **No** | +| `3` (`failed`) | Invalidated by a conflicting transaction (Double-Spend). | **No** | +| `4` (`orphaned`) | Coinbase tx that was reorged out (Invalid). | **No** | -*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status='published'`) or orphaned (`Status='orphaned'` and `BlockHeight IS NULL`).* +*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status = 1`, `published`) or orphaned (`Status = 4`, `orphaned`, and `BlockHeight IS NULL`).* + +Factual-balance note: the SQL Store layer treats both `pending` and +`published` parent transactions as part of the current live UTXO set. More +conservative spendability policy (for example, excluding `pending` parents) is +applied by higher-level callers on top of that factual base. *Note: There is no "Abandoned" state. A broadcast transaction cannot be safely abandoned; it can only be invalidated by double-spending its inputs.* @@ -178,12 +183,12 @@ A UTXO does not store its own status field. Its status is calculated dynamically | UTXO State | Parent Tx Status | `BlockHeight` | `SpentBy` Pointer | Meaning | | :--- | :--- | :--- | :--- | :--- | -| **Confirmed** | `published` | `NOT NULL` | `NULL` | Available, mature funds. | -| **Unconfirmed** | `published` | `NULL` | `NULL` | Incoming funds, risk of RBF. | -| **Immature** | `published` (Coinbase) | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | -| **Spent** | `published` | `NOT NULL` | `NOT NULL` | History. We used this money. | -| **Dead (Permanent)** | `failed` / `replaced` | *Any* | *Any* | Permanently invalid output from a failed attempt. | -| **Dead (Recoverable)** | `orphaned` (coinbase) | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | +| **Confirmed** | `1` (`published`) | `NOT NULL` | `NULL` | Available, mature funds. | +| **Unconfirmed** | `1` (`published`) | `NULL` | `NULL` | Incoming funds, risk of RBF. | +| **Immature** | `1` (`published`) coinbase | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | +| **Spent** | `1` (`published`) | `NOT NULL` | `NOT NULL` | History. We used this money. | +| **Dead (Permanent)** | `3` / `2` (`failed` / `replaced`) | *Any* | *Any* | Permanently invalid output from a failed attempt. | +| **Dead (Recoverable)** | `4` (`orphaned`) coinbase | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | --- @@ -213,7 +218,7 @@ When the blockchain reorganizes (disconnects blocks): Implementation note: The SQL `blocks` table models the current best chain. During a disconnect, the best-chain association is removed (for example by deleting the `blocks` row at that height and inserting the new one). The coinbase status update MUST be atomic with clearing `BlockHeight` to avoid an invalid "unconfirmed coinbase" state. -Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status='published'` atomically (one SQL statement within a transaction) to satisfy coinbase invariants. +Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status = 1` (`published`) atomically (one SQL statement within a transaction) to satisfy coinbase invariants. Reconfirmation semantics: * The transaction keeps the same `tx_hash` and is associated with a new `block_height`. @@ -237,7 +242,7 @@ graph TD When the wallet detects a replacement transaction (Tx B) for an existing unconfirmed transaction (Tx A): 1. **Detection:** Tx B spends the same inputs as Tx A. 2. **Update:** The input UTXOs are updated to point to Tx B (`SpentBy = TxB`). -3. **Archive:** Tx A is marked as `status = 'replaced'`. It remains in the DB for history but is ignored by balance queries. +3. **Archive:** Tx A is marked as `status = 2` (`replaced`). It remains in the DB for history but is ignored by balance queries. ### 5.3 Upstream Double-Spend (Invalidation) @@ -260,7 +265,7 @@ When a transaction (Tx C) becomes invalid because *its input* was spent by a con 2. **Conflict:** We have Tx C (unconfirmed) which also spends UTXO X. 3. **Resolution:** * Update UTXO X: `SpentBy = TxD`. - * Mark Tx C: `Status = 'failed'`. + * Mark Tx C: `Status = 3` (`failed`). * **Cascade:** Any UTXOs created by Tx C are now effectively "Dead" (because their parent is `failed`). * Any transactions spending those dead UTXOs must also be marked `failed` (recursive invalidation). @@ -278,21 +283,32 @@ This can be implemented with an application-side loop (portable across databases ### 5.4 Coin Selection Coin selection queries focus purely on the UTXO set: 1. Filter by `SpentBy IS NULL`. -2. Join Parent Transaction to ensure `Status IN ('published', 'pending')`. - * *Note:* Including `pending` allows chaining unbroadcast transactions (Zero-Latency Chaining), but creates a strict dependency: the parent must be broadcast before the child. - * This is an advanced feature and should be opt-in for conservative policies. - * If confirmation is required (e.g., for safety), add `AND BlockHeight IS NOT NULL`. +2. Join the parent transaction and choose a caller-specific live-status + filter. + * Conservative callers can require `Status = 1` (`published`). + * Zero-latency chaining can opt into `Status IN (0, 1)` (`pending`, + `published`), but this creates a strict dependency: the parent must be + broadcast before the child. + * If confirmation is required (e.g., for safety), add + `AND BlockHeight IS NOT NULL`. 3. Filter out active `Leases`. -Important: The `spendable_utxos` view described in ADR 0006 does not exclude leases (because it depends on the database's current time function and requires a `NOT EXISTS` against `utxo_leases`). Coin selection must explicitly filter `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`. +Important: There is no dedicated `spendable_utxos` view in the current branch. +Coin selection should build directly on factual UTXO queries and then apply its +own policy filters for pending parents, active leases, and immature coinbase +outputs. Active leases should be excluded using the caller's UTC `now` value, +not an implicit database-local timestamp. Transaction reconstruction note: This model optimizes the high-frequency UTXO read path. Reconstructing a full transaction view (inputs/outputs for UI/history) is inherently more join-heavy and should be treated as a lower-frequency, separately optimized read path. ### 5.5 Multi-Wallet Concurrency & Consistency -When multiple wallets share a single database, all `wtxmgr` rows are scoped by `wallet_id` (see ADR 0006). This avoids conflicts and makes correctness properties explicit. +When multiple wallets share a single database, the transaction graph remains +wallet-scoped through `transactions.wallet_id`, and other rows either carry +`wallet_id` directly or derive wallet ownership through that transaction graph. +This avoids conflicts and makes correctness properties explicit. * **Consistency model:** The system assumes ACID semantics from the database. Reorg handling, status transitions, and lease acquisition must be performed as explicit SQL transactions. * **Concurrency primitive:** UTXO leases are the wallet-level lock. Coin selection must exclude leased UTXOs, and lease acquisition should be treated as part of the same atomic unit as input selection. -* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined place (query helper/view + height parameter) to avoid accidental selection of immature funds. +* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined caller-side policy path to avoid accidental selection of immature funds. -Suggested enforcement: a parameterized query helper or SQL function that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. +Suggested enforcement: a parameterized caller-side query helper or composition layer that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. From ff0b43bbdb86a9e8e398380ef3b18564fbc2782f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:17 +0800 Subject: [PATCH 140/695] wallet: prep db TxStore/UTXOStore interfaces --- wallet/internal/db/data_types.go | 196 ++++++++++++++++++++++++++++--- wallet/internal/db/interface.go | 106 +++++++++++++---- wallet/internal/db/kvdb/utxo.go | 5 +- wallet/mock_test.go | 8 +- wallet/tx_publisher.go | 7 +- 5 files changed, 269 insertions(+), 53 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 280e3fed33..2bb588add9 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -4,6 +4,7 @@ package db import ( + "math" "time" "github.com/btcsuite/btcd/address/v2" @@ -12,6 +13,19 @@ import ( "github.com/btcsuite/btcd/wire/v2" ) +const ( + // UnminedHeight is a sentinel value used in UtxoInfo.Height to indicate + // that the UTXO is unconfirmed. + // + // Database rows represent an unconfirmed creating transaction by setting + // `transactions.block_height` to NULL. The store layer maps that NULL to + // this sentinel value so callers can continue to treat UtxoInfo.Height + // as a non-nullable `uint32`. + // + // NOTE: This value must never overlap with a real block height. + UnminedHeight uint32 = math.MaxUint32 +) + // ============================================================================ // Data Types & Method Parameters // ============================================================================ @@ -703,6 +717,75 @@ type ListAddressesQuery struct { // TxStore Types // -------------------- +// TxStatus represents the wallet-relative validity state of a transaction. +// +// The value is stored in the `transactions.status` column as a compact numeric +// code so hot-path predicates and indexes do not pay the storage/index cost of +// repeated status strings. +// +// The enum values MUST match the numeric codes enforced by migration +// `000007_transactions` in both Postgres and SQLite. +type TxStatus uint8 + +const ( + // TxStatusPending indicates a locally-created transaction that has not yet + // been broadcast. + // + // Callers use this state when they need the store to retain a locally + // authored transaction before network publication. + TxStatusPending TxStatus = iota + + // TxStatusPublished indicates a transaction that is still considered + // valid by the wallet and is either unconfirmed in the mempool or + // confirmed in the current best chain. + // + // The two cases share one validity status because Block already tells the + // caller whether the transaction is mined. Keeping both under + // TxStatusPublished avoids contradictory combinations such as + // "confirmed but not published" and keeps this field focused on whether the + // wallet still treats the transaction as live. + TxStatusPublished + + // TxStatusReplaced indicates a transaction that was invalidated by a + // competing transaction spending the same inputs via RBF. + TxStatusReplaced + + // TxStatusFailed indicates a transaction that was invalidated by a + // competing transaction spending the same inputs (double-spend). + TxStatusFailed + + // TxStatusOrphaned indicates a coinbase transaction that was reorged out of + // the best chain. + // + // This state is reserved for coinbase transactions. Non-coinbase rows must + // use a different terminal state such as TxStatusFailed or + // TxStatusReplaced. + TxStatusOrphaned +) + +// String returns the human-readable name of one transaction status code. +func (s TxStatus) String() string { + switch s { + case TxStatusPending: + return "pending" + + case TxStatusPublished: + return "published" + + case TxStatusReplaced: + return "replaced" + + case TxStatusFailed: + return "failed" + + case TxStatusOrphaned: + return "orphaned" + + default: + return "unknown" + } +} + // TxInfo represents the details of a transaction relevant to the wallet. type TxInfo struct { // Hash is the transaction hash. @@ -719,6 +802,11 @@ type TxInfo struct { // and non-nil for mined (confirmed) transactions. Block *Block + // Status is the wallet-relative validity state of the transaction. + // + // For confirmed transactions, Status is always TxStatusPublished. + Status TxStatus + // Label is a user-defined label for the transaction. Label string } @@ -734,6 +822,40 @@ type CreateTxParams struct { // Tx is the transaction to record. Tx *wire.MsgTx + // Received is the timestamp when the wallet learned about the transaction. + // + // Callers supply this explicitly so import/recovery paths can preserve the + // wallet-observed time instead of defaulting to insertion time. + // + // This timestamp is stored in the database as UTC. + Received time.Time + + // Block optionally records the transaction as already confirmed in the + // provided block. When nil, the transaction is treated as unmined. + // + // The Store layer records factual transaction state. A non-nil Block means + // the caller is inserting a row already anchored to a specific block in + // wallet history; it does not ask the Store layer to infer publishing or + // confirmation policy on the caller's behalf. + // + // NOTE: Coinbase transactions cannot exist in the mempool. Callers MUST + // provide a non-nil Block when recording coinbase transactions. + Block *Block + + // Status is the initial wallet-relative validity state for the + // transaction. + // + // This Store-layer API inserts an already-constructed transaction row. It + // does not build, sign, publish, or infer higher-level wallet policy. + // Callers must therefore set Status explicitly instead of asking the Store + // to guess how an app-layer workflow intends to use the row. + // + // Unmined inserts choose between TxStatusPending and TxStatusPublished. + // Confirmed inserts (Block non-nil) must use TxStatusPublished to satisfy + // the transaction-state invariants. TxStatusOrphaned is reserved for + // coinbase rows and must not be used for ordinary transactions. + Status TxStatus + // Label is an optional label for the transaction. Label string @@ -753,12 +875,28 @@ type CreditData struct { Index uint32 // Address is the address that received the credit. + // + // NOTE: This field is for display only. The database layer should match the + // credit to an address row by the output's script_pub_key + // (`params.Tx.TxOut[Index].PkScript`), which is the canonical key + // used by the address schema. This is especially important for + // imported or script-based addresses, where wallet ownership is + // defined by the stored script material rather than by one canonical + // encoded address string. + // + // Examples: + // - A standard P2WPKH credit usually has one obvious bech32 address for UI + // display, but the wallet still keys ownership off the exact witness + // program bytes recorded in script_pub_key. + // - An imported script address (`waddrmgr.Script`, + // `waddrmgr.WitnessScript`, or `waddrmgr.TaprootScript`) is owned because + // the wallet imported the script material; the encoded address, if + // any, is only a presentation form of that script. Address address.Address } -// UpdateTxParams contains the parameters for updating a transaction record. -// Fields are pointers to allow for partial updates. -type UpdateTxParams struct { +// UpdateTxLabelParams contains the parameters for updating a transaction label. +type UpdateTxLabelParams struct { // WalletID is the ID of the wallet containing the transaction. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -768,11 +906,10 @@ type UpdateTxParams struct { // Txid is the hash of the transaction to update. Txid chainhash.Hash - // Block is the new block metadata for the transaction. - Block *Block - // Label is the new label for the transaction. - Label *string + // + // The empty string is a valid value and clears any prior label. + Label string } // GetTxQuery contains the parameters for querying a transaction. While a @@ -849,6 +986,8 @@ type UtxoInfo struct { FromCoinBase bool // Height is the block height of the UTXO. + // + // Unconfirmed UTXOs use the sentinel value UnminedHeight. Height uint32 } @@ -872,17 +1011,16 @@ type ListUtxosQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // Account is an optional filter to list UTXOs only for a specific - // account. + // Account is an optional BIP44 account-number filter. Account *uint32 - // MinConfs is the minimum number of confirmations for a UTXO to be - // included. - MinConfs int32 + // MinConfs optionally requires each returned UTXO to have at least this + // many confirmations. + MinConfs *int32 - // MaxConfs is the maximum number of confirmations for a UTXO to be - // included. - MaxConfs int32 + // MaxConfs optionally requires each returned UTXO to have at most this + // many confirmations. + MaxConfs *int32 } // LeaseOutputParams contains the parameters for leasing a UTXO. @@ -930,6 +1068,16 @@ type LeasedOutput struct { Expiration time.Time } +// BalanceResult represents one wallet-scoped balance view after applying the +// requested filters. +type BalanceResult struct { + // Total is the sum of every matching live UTXO, including leased outputs. + Total btcutil.Amount + + // Locked is the subset of Total currently covered by active output leases. + Locked btcutil.Amount +} + // BalanceParams contains the parameters for the Balance method. type BalanceParams struct { // WalletID is the ID of the wallet to query. @@ -938,9 +1086,21 @@ type BalanceParams struct { // databases (signed 64-bit integers). WalletID uint32 - // MinConfirms is the minimum number of confirmations a UTXO must have - // to be included in the balance calculation. - MinConfirms int32 + // Account optionally restricts the balance to one BIP44 account number. + Account *uint32 + + // MinConfs optionally requires each counted output to have at least + // this many confirmations. + MinConfs *int32 + + // MaxConfs optionally requires each counted output to have at most + // this many confirmations. + MaxConfs *int32 + + // CoinbaseMaturity optionally requires coinbase outputs to have at + // least this many confirmations before they count toward the returned + // balance result. Non-coinbase outputs ignore this filter. + CoinbaseMaturity *int32 } // LockID represents a unique context-specific ID assigned to an output lock. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 32899b851f..d83a0afb54 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -3,8 +3,6 @@ package db import ( "context" "errors" - - "github.com/btcsuite/btcd/btcutil/v2" ) var ( @@ -72,6 +70,19 @@ var ( // created within a branch because the address index counter has reached // its maximum representable value. ErrMaxAddressIndexReached = errors.New("max address index reached") + + // ErrTxNotFound is returned when a transaction is not found in the + // database. + ErrTxNotFound = errors.New("transaction not found") + + // ErrUtxoNotFound is returned when a UTXO is not found in the database. + ErrUtxoNotFound = errors.New("utxo not found") + + // ErrTxInputConflict is returned when CreateTx references a wallet-owned + // input that is already spent by another live wallet transaction. + ErrTxInputConflict = errors.New( + "transaction input conflicts with live wallet spend", + ) ) // Store defines the set of database operations used by the wallet. @@ -227,18 +238,36 @@ type AddressStore interface { // TxStore defines the database actions for managing transaction records. type TxStore interface { - // CreateTx atomically records a transaction and its associated credits - // in the database. This is a single atomic operation that also handles - // the corresponding UTXO state changes: it will delete any UTXOs spent - // by the new transaction's inputs and create new UTXOs for any of its - // outputs that are spendable by the wallet. This ensures that the - // transaction record and the UTXO set are always consistent. + // CreateTx atomically records a transaction row and its associated credits + // in the database. This Store-layer API persists already-constructed + // wallet history; it does not build or publish transactions on the + // caller's behalf. + // + // The same write also updates the corresponding UTXO state: it marks any + // wallet-owned outputs referenced by the new transaction's inputs as spent + // and creates new UTXOs for any outputs that are spendable by the wallet. + // This keeps the transaction record and UTXO set consistent. + // + // CreateTx is also responsible for recording transaction metadata + // required by higher-level wallet policy: + // - the received timestamp (stored in UTC), + // - the optional block assignment (confirmed vs unconfirmed), + // - the caller-selected initial transaction status. + // + // The create path does not infer whether an unmined transaction should + // start in TxStatusPending or TxStatusPublished; callers must provide that + // choice explicitly in CreateTxParams. CreateTx(ctx context.Context, params CreateTxParams) error - // UpdateTx updates an existing transaction record in the database. It - // takes a context and UpdateTxParams, returning an error if the + // UpdateTxLabel updates an existing transaction record in the database. It + // takes a context and UpdateTxLabelParams, returning an error if the // transaction cannot be found or updated. - UpdateTx(ctx context.Context, params UpdateTxParams) error + // + // UpdateTxLabel is intentionally narrow: it only updates the user-visible + // label. Block assignment, rollback, and status transitions belong to + // dedicated internal queries used by wallet synchronization and + // replacement handling. + UpdateTxLabel(ctx context.Context, params UpdateTxLabelParams) error // GetTx retrieves a transaction record by its hash. It takes a context // and GetTxQuery, returning a TxInfo struct or an error if the @@ -254,9 +283,15 @@ type TxStore interface { // returning a slice of TxInfo or an error if the retrieval fails. ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) - // DeleteTx removes an unmined transaction from the store. It takes a - // context and DeleteTxParams, returning an error if the transaction is - // not found or the deletion fails. + // DeleteTx removes a live unconfirmed transaction from the store. It + // takes a context and DeleteTxParams, returning an error if the + // transaction is not found or the deletion fails. + // + // DeleteTx is intentionally narrower than a generic + // "delete any transaction" API. Orphaned, replaced, and failed rows remain + // part of the wallet's historical view for audit, reorg, and replacement + // handling and therefore must not be erased through the ordinary + // unconfirmed-deletion path. DeleteTx(ctx context.Context, params DeleteTxParams) error // RollbackToBlock removes all blocks at and after a given height, @@ -268,6 +303,10 @@ type TxStore interface { // approach guarantees that the rollback is either fully completed or // not at all. // + // NOTE: This method has no wallet ID parameter and is therefore a + // DB-wide operation affecting all wallets that share the same `blocks` + // table. + // // TODO(yy): explore performance improvement for this method. RollbackToBlock(ctx context.Context, height uint32) error } @@ -279,36 +318,53 @@ type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details // or an error if the UTXO is not found or has been spent. + // + // GetUtxo treats outputs created by both live unconfirmed states + // (TxStatusPending and TxStatusPublished) as present in the wallet's + // UTXO set. GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) // that match the provided query parameters. This can be used to list // all UTXOs or filter them by account or confirmation status. + // + // ListUTXOs includes outputs from live unconfirmed parent transactions. + // Spendability policy such as coinbase maturity, lease state, or whether + // a caller wants to exclude TxStatusPending parents belongs in higher-level + // selection logic. ListUTXOs(ctx context.Context, query ListUtxosQuery) ([]UtxoInfo, error) // LeaseOutput locks a specific UTXO for a given duration, preventing // it from being used in coin selection. This is useful for reserving // UTXOs for a specific purpose, such as a pending transaction. The // method returns the full lease information, including its expiration - // time. + // time. If another active lease already exists with a different lock ID, + // the call returns ErrOutputAlreadyLeased. LeaseOutput(ctx context.Context, params LeaseOutputParams) ( *LeasedOutput, error) // ReleaseOutput unlocks a previously leased UTXO, making it available - // for coin selection again. The lock ID must match the one used to - // lease the output. + // for coin selection again. Unlocking an already-unlocked or expired lease + // is a no-op. If another active lease still exists for the output, the lock + // ID must match the one that holds it or the call returns + // ErrOutputUnlockNotAllowed. ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error - // ListLeasedOutputs returns a slice of all currently leased UTXOs. - // This can be used to inspect which outputs are currently locked and - // when their leases expire. + // ListLeasedOutputs returns a slice of all currently leased live UTXOs. + // This can be used to inspect which still-unspent outputs are currently + // locked and when their leases expire. ListLeasedOutputs(ctx context.Context, walletID uint32) ( []LeasedOutput, error) - // Balance returns the total spendable balance of the wallet, - // calculated from the UTXO set. The minConfirms parameter specifies - // the minimum number of confirmations a UTXO must have to be included - // in the balance calculation. + // Balance returns a wallet-scoped balance view for the current unspent UTXO + // set after applying any optional caller-supplied filters. + // + // The zero-value BalanceParams request the wallet's live factual balance. + // Callers may narrow that view by account, confirmation range, lease + // or coinbase maturity when they need a workflow-specific balance policy + // from the public store interface. The returned BalanceResult always uses + // the same filtered base set for both Total and Locked so callers can + // reason about lease state without issuing a second balance query. Balance(ctx context.Context, params BalanceParams) ( - btcutil.Amount, error) + BalanceResult, error) } diff --git a/wallet/internal/db/kvdb/utxo.go b/wallet/internal/db/kvdb/utxo.go index 2f53d19f83..295d9dd65f 100644 --- a/wallet/internal/db/kvdb/utxo.go +++ b/wallet/internal/db/kvdb/utxo.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" - "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -100,7 +99,7 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, // Balance is not yet implemented for kvdb. func (s *Store) Balance(ctx context.Context, - _ db.BalanceParams) (btcutil.Amount, error) { + _ db.BalanceParams) (db.BalanceResult, error) { - return 0, notImplemented(ctx, "Balance") + return db.BalanceResult{}, notImplemented(ctx, "Balance") } diff --git a/wallet/mock_test.go b/wallet/mock_test.go index ab31462cfe..5240618ad2 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -101,16 +101,16 @@ func (m *mockStore) ListLeasedOutputs(ctx context.Context, // Balance implements the db.UTXOStore interface. func (m *mockStore) Balance(ctx context.Context, - params db.BalanceParams) (btcutil.Amount, error) { + params db.BalanceParams) (db.BalanceResult, error) { args := m.Called(ctx, params) - amount, ok := args.Get(0).(btcutil.Amount) + result, ok := args.Get(0).(db.BalanceResult) if !ok { - return 0, args.Error(1) + return db.BalanceResult{}, args.Error(1) } - return amount, args.Error(1) + return result, args.Error(1) } // mockTxStore is a mock implementation of the wtxmgr.TxStore interface. diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 2be2c7b386..742fc6ef07 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -185,9 +185,10 @@ func (w *Wallet) checkMempool(ctx context.Context, log.Infof("Tx %v already broadcasted", tx.TxHash()) - // TODO(yy): Add a new method UpdateTxLabel to allow updating - // the label of a tx. With this change, the label passed in - // will be ignored if the tx is already known. + // TODO(yy): Update the Store layer with the caller-supplied + // label when the transaction is already known. With this + // change, the label passed in will be ignored if the tx is + // already known. return errAlreadyBroadcasted // If the backend does not support the mempool acceptance test, we'll From 2fed11b1f36f16032f4b9da76d2d117d76c2a169 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:33 +0800 Subject: [PATCH 141/695] wallet: add wtxmgr migrations (ADR 0006) --- .../postgres/000005_accounts.up.sql | 5 +- .../postgres/000006_addresses.up.sql | 5 +- .../postgres/000007_transactions.down.sql | 6 + .../postgres/000007_transactions.up.sql | 167 +++++++++++++++++ .../migrations/postgres/000008_utxos.down.sql | 7 + .../migrations/postgres/000008_utxos.up.sql | 116 ++++++++++++ .../postgres/000009_tx_replacements.down.sql | 4 + .../postgres/000009_tx_replacements.up.sql | 52 ++++++ .../postgres/000010_utxo_leases.down.sql | 7 + .../postgres/000010_utxo_leases.up.sql | 54 ++++++ .../sqlite/000007_transactions.down.sql | 5 + .../sqlite/000007_transactions.up.sql | 170 ++++++++++++++++++ .../migrations/sqlite/000008_utxos.down.sql | 4 + .../db/migrations/sqlite/000008_utxos.up.sql | 126 +++++++++++++ .../sqlite/000009_tx_replacements.down.sql | 4 + .../sqlite/000009_tx_replacements.up.sql | 55 ++++++ .../sqlite/000010_utxo_leases.down.sql | 4 + .../sqlite/000010_utxo_leases.up.sql | 53 ++++++ 18 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 wallet/internal/db/migrations/postgres/000007_transactions.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000007_transactions.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000008_utxos.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000008_utxos.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000007_transactions.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000007_transactions.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000008_utxos.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000008_utxos.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index 5108396780..cea4682d93 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -61,8 +61,9 @@ CREATE TABLE accounts ( -- Encrypted public key for the account. encrypted_public_key BYTEA, - -- Timestamp when the account was created. Automatically set by the database. - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Timestamp when the account was created. Automatically set by the database + -- in UTC. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), -- Next index to use for external addresses (branch 0) next_external_index BIGINT NOT NULL DEFAULT 0, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 3e97d62d35..2c13ad1a4b 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -37,8 +37,9 @@ CREATE TABLE addresses ( -- account key. pub_key BYTEA, - -- Timestamp when the address was created. Automatically set by the database. - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Timestamp when the address was created. Automatically set by the database + -- in UTC. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), -- Branch and index are set together for HD-derived addresses and both -- NULL for imported addresses. diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.down.sql b/wallet/internal/db/migrations/postgres/000007_transactions.down.sql new file mode 100644 index 0000000000..e2b1d19e27 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000007_transactions.down.sql @@ -0,0 +1,6 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_set_coinbase_orphaned_on_disconnect ON transactions; +DROP FUNCTION IF EXISTS set_coinbase_orphaned_on_disconnect(); +DROP TABLE IF EXISTS transactions; diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql new file mode 100644 index 0000000000..c7bd9b5f16 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql @@ -0,0 +1,167 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Transactions table stores wallet-scoped blockchain transactions and their +-- wallet-relative validity/confirmation state. +CREATE TABLE transactions ( + -- Reference to the wallet that owns this transaction row. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the transaction, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Secondary unique constraint used as the referenced key for wallet-scoped + -- child relations such as utxos and tx_replacements. + CONSTRAINT uidx_transactions_wallet_id_id UNIQUE (wallet_id, id), + + -- Transaction hash (txid) (32 bytes). Unique per wallet. + tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), + + -- Raw serialized transaction bytes. + -- + -- The SQL schema does not store a fully normalized input/output graph for + -- every transaction. Persisting raw_tx lets read paths reconstruct the full + -- wire.MsgTx when callers need the serialized transaction or when rollback / + -- invalidation walks need to inspect transaction inputs. + -- + -- NOTE: Hot-path queries (balance/coin selection) SHOULD avoid selecting + -- this column. + raw_tx BYTEA NOT NULL, + + -- Confirmation state: + -- NULL = Unconfirmed (mempool) + -- INT = Confirmed (mined) + -- + -- ON DELETE SET NULL: If a block is reorged, the transaction becomes + -- unconfirmed. + block_height INTEGER REFERENCES blocks (block_height) ON DELETE SET NULL, + + -- Validity state (soft deletion). + -- + -- Store the status code inline instead of via a lookup table because this + -- enum is tiny, closed, and appears on hot-path predicates/indexes. + -- + -- Status codes: + -- 0 = pending + -- 1 = published + -- 2 = replaced + -- 3 = failed + -- 4 = orphaned + tx_status SMALLINT NOT NULL, + + -- Absolute wall clock time, supplied by the caller and stored in UTC + -- without timezone info. + -- + -- NOTE: There is intentionally no DEFAULT current_timestamp here because + -- import/recovery flows may need to preserve the wallet-observed receive + -- time instead of the row insertion time. + received_time TIMESTAMP NOT NULL, + + -- Whether this transaction is a coinbase transaction. + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Optional user-provided label. Empty string means "no label". + -- + tx_label VARCHAR(500) NOT NULL DEFAULT '', + + -- Wallet-scoped uniqueness lets different wallets record the same network + -- txid independently while keeping every child lookup anchored to one + -- wallet. + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + + -- Keep the persisted validity state closed over the finite set of states + -- the store knows how to interpret and transition between. + CONSTRAINT valid_status CHECK ( + tx_status IN (0, 1, 2, 3, 4) + ), + + -- Non-coinbase transactions cannot enter the orphaned state. That state is + -- reserved for coinbase rows that were disconnected from the best chain. + CONSTRAINT check_orphaned_coinbase_only CHECK ( + tx_status != 4 OR is_coinbase + ), + + -- A transaction attached to a block is treated as confirmed wallet history. + -- For confirmed rows, the only valid status is `published`; every other + -- status represents either blockless local state or disconnected history. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR tx_status = 1 + ), + + -- Coinbase transactions cannot exist in the local-only pre-broadcast state + -- because they are created by mining, not by wallet authorship. + CONSTRAINT check_coinbase_not_pending CHECK ( + NOT (is_coinbase AND tx_status = 0) + ), + + -- Coinbase rows may only be recorded in their mined form or in the + -- orphaned form produced by a disconnect/reorg transition. + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase + OR (block_height IS NOT NULL AND tx_status = 1) + OR (block_height IS NULL AND tx_status = 4) + ) +); + +-- Optimization for live unconfirmed transaction lookups. +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL AND tx_status IN (0, 1); + +-- Optimization for wallet-scoped joins into the live transaction set. +CREATE INDEX idx_transactions_live_by_wallet +ON transactions (wallet_id, id) +WHERE tx_status IN (0, 1); + +-- Optimization for wallet-scoped blockless history reads ordered by newest +-- receive time first. +CREATE INDEX idx_transactions_blockless_history +ON transactions (wallet_id, received_time DESC, id DESC) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries. +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for rollback/disconnect paths that only know the confirmed block +-- height and then fan out to affected wallet rows. +CREATE INDEX idx_transactions_by_confirmed_height +ON transactions (block_height, wallet_id, id) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries. +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Reorg handling for coinbase transactions. +-- +-- PostgreSQL checks CHECK constraints immediately. When a block is deleted, the +-- FK `ON DELETE SET NULL` action rewrites child `transactions.block_height` +-- values to NULL. Coinbase rows cannot stay in `published` once that happens, +-- because the schema requires: +-- - coinbase + confirmed block => status = 1 (`published`) +-- - coinbase + no block => status = 4 (`orphaned`) +-- +-- PostgreSQL can solve this on the child-row update path. The FK action causes +-- a real `UPDATE OF block_height ON transactions`, so a BEFORE UPDATE trigger +-- can rewrite the same child row from `(published, block)` to +-- `(orphaned, NULL block)` before the new version is checked. +CREATE FUNCTION set_coinbase_orphaned_on_disconnect() RETURNS TRIGGER AS $$ +BEGIN + -- Detect the disconnect transition caused by the FK action on block delete. + IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL + AND NEW.is_coinbase THEN + -- Only coinbase rows need rewriting here. Ordinary transactions may + -- become unconfirmed while keeping their existing non-orphaned status. + NEW.tx_status := 4; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect +BEFORE UPDATE OF block_height ON transactions +FOR EACH ROW +EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.down.sql b/wallet/internal/db/migrations/postgres/000008_utxos.down.sql new file mode 100644 index 0000000000..ad811f4d6b --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000008_utxos.down.sql @@ -0,0 +1,7 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_assert_utxo_wallet_consistency_insert ON utxos; +DROP TRIGGER IF EXISTS trg_assert_utxo_wallet_consistency_update ON utxos; +DROP FUNCTION IF EXISTS assert_utxo_wallet_consistency(); +DROP TABLE IF EXISTS utxos; diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.up.sql b/wallet/internal/db/migrations/postgres/000008_utxos.up.sql new file mode 100644 index 0000000000..09d73e10ce --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000008_utxos.up.sql @@ -0,0 +1,116 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- UTXOs table stores wallet-owned credits (spent and unspent). +CREATE TABLE utxos ( + -- DB ID of the UTXO, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Creation outpoint (tx_id + output_index). + tx_id BIGINT NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + -- Output amount in satoshis. + amount BIGINT NOT NULL CHECK (amount >= 0), + + -- Reference to the address record that owns the output. + -- + -- NOTE: The address-manager schema does not expose wallet_id on addresses, + -- so ownership is derived via addresses -> accounts -> key_scopes and + -- enforced by trigger below. + address_id BIGINT NOT NULL REFERENCES addresses (id) ON DELETE RESTRICT, + + -- Spending input (when spent). + spent_by_tx_id BIGINT, + spent_input_index INTEGER CHECK ( + spent_input_index IS NULL OR spent_input_index >= 0 + ), + + -- The creating transaction anchors the outpoint to one wallet-scoped + -- transaction history. + CONSTRAINT fkey_utxos_tx FOREIGN KEY (tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- Manual pruning note: + -- The reference ADR uses ON DELETE SET NULL here to restore spendability + -- when the spending transaction is physically deleted. This repository + -- uses ON DELETE RESTRICT and requires an explicit pruning operation that + -- clears spent_by_* first. + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (spent_by_tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- spent_by_tx_id and spent_input_index together model one logical pointer + -- to the spending input, so they must transition between NULL and non-NULL + -- as a pair. + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) + OR (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + -- Each wallet-local transaction records a given network outpoint at most + -- once, which keeps credit insertion idempotent and lets outpoint lookups + -- resolve to one row. + CONSTRAINT uidx_utxos_outpoint UNIQUE (tx_id, output_index) +); + +-- Optimization for balance queries (index-only scan). +CREATE INDEX idx_utxos_unspent +ON utxos (tx_id, amount, output_index) +WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent). +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction. +CREATE INDEX idx_utxos_spent_by ON utxos (spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction. +CREATE INDEX idx_utxos_by_tx ON utxos (tx_id); + +CREATE FUNCTION assert_utxo_wallet_consistency() RETURNS TRIGGER AS $$ +DECLARE + creating_wallet_id BIGINT; + address_wallet_id BIGINT; + spending_wallet_id BIGINT; +BEGIN + SELECT t.wallet_id INTO creating_wallet_id + FROM transactions AS t + WHERE t.id = NEW.tx_id; + + SELECT ks.wallet_id INTO address_wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = NEW.address_id; + + IF creating_wallet_id IS NOT NULL + AND address_wallet_id IS NOT NULL + AND creating_wallet_id != address_wallet_id THEN + RAISE EXCEPTION 'utxo creating tx wallet and address wallet must match'; + END IF; + + IF NEW.spent_by_tx_id IS NOT NULL THEN + SELECT t.wallet_id INTO spending_wallet_id + FROM transactions AS t + WHERE t.id = NEW.spent_by_tx_id; + + IF creating_wallet_id IS NOT NULL + AND spending_wallet_id IS NOT NULL + AND creating_wallet_id != spending_wallet_id THEN + RAISE EXCEPTION 'utxo spending tx wallet must match creating tx wallet'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_insert +BEFORE INSERT ON utxos +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_wallet_consistency(); + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_update +BEFORE UPDATE OF tx_id, address_id, spent_by_tx_id ON utxos +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_wallet_consistency(); diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql b/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql new file mode 100644 index 0000000000..19f69ebbc9 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS tx_replacements; diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql b/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql new file mode 100644 index 0000000000..0f4ba63f67 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql @@ -0,0 +1,52 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- tx_replacements stores the audit edges between replaced and replacement +-- wallet-scoped transactions. +CREATE TABLE tx_replacements ( + -- Reference to the wallet that owns this replacement edge. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the replacement edge. + id BIGSERIAL NOT NULL, + + -- The direct victim transaction in the replacement pair. + replaced_tx_id BIGINT NOT NULL, + + -- The direct winner transaction in the replacement pair. + replacement_tx_id BIGINT NOT NULL, + + -- Creation timestamp used for replacement-edge traversal ordering. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), + + -- Composite primary key is intentional: it keeps the audit row wallet- + -- scoped and leaves room for wallet-scoped foreign keys if they are needed + -- later. + CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), + + -- The audit edge must stay inside one wallet-scoped transaction graph. + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY ( + wallet_id, replaced_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY ( + wallet_id, replacement_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + + -- A transaction cannot replace itself. + CONSTRAINT check_not_self_replacement CHECK ( + replaced_tx_id != replacement_tx_id + ), + + -- One directed replacement edge may only be recorded once. + CONSTRAINT uidx_tx_replacements_edge UNIQUE ( + wallet_id, replaced_tx_id, replacement_tx_id + ) +); + +-- Optimization for traversing direct victims from a winner. +CREATE INDEX idx_tx_replacements_by_replacement +ON tx_replacements (wallet_id, replacement_tx_id, created_at, id); + +-- Optimization for traversing direct winners from a victim. +CREATE INDEX idx_tx_replacements_by_replaced +ON tx_replacements (wallet_id, replaced_tx_id, created_at, id); diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql b/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql new file mode 100644 index 0000000000..4b6734c6e2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql @@ -0,0 +1,7 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_assert_utxo_lease_wallet_consistency_insert ON utxo_leases; +DROP TRIGGER IF EXISTS trg_assert_utxo_lease_wallet_consistency_update ON utxo_leases; +DROP FUNCTION IF EXISTS assert_utxo_lease_wallet_consistency(); +DROP TABLE IF EXISTS utxo_leases; diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql b/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql new file mode 100644 index 0000000000..eac87ab666 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql @@ -0,0 +1,54 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- utxo_leases stores transient application-level locks over wallet-owned UTXOs. +CREATE TABLE utxo_leases ( + -- Reference to the wallet that owns the leased UTXO. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- The leased UTXO row. + utxo_id BIGINT PRIMARY KEY, + + -- Caller-provided lock ID. It must stay fixed-width so lease ownership can + -- be compared without decoding application-specific payloads. + lock_id BYTEA NOT NULL CHECK (length(lock_id) = 32), + + -- UTC-normalized lease expiration timestamp. + expires_at TIMESTAMP NOT NULL, + + -- The leased output must exist in the UTXO set. Wallet consistency is + -- enforced by trigger below. + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (utxo_id) + REFERENCES utxos (id) ON DELETE CASCADE +); + +-- Optimization for wallet-scoped lease cleanup and active-lease scans. +CREATE INDEX idx_utxo_leases_wallet_expires_at +ON utxo_leases (wallet_id, expires_at); + +CREATE FUNCTION assert_utxo_lease_wallet_consistency() RETURNS TRIGGER AS $$ +DECLARE + utxo_wallet_id BIGINT; +BEGIN + SELECT t.wallet_id INTO utxo_wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = NEW.utxo_id; + + IF utxo_wallet_id IS NOT NULL AND NEW.wallet_id != utxo_wallet_id THEN + RAISE EXCEPTION 'utxo lease wallet must match leased utxo wallet'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_insert +BEFORE INSERT ON utxo_leases +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_lease_wallet_consistency(); + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_update +BEFORE UPDATE OF wallet_id, utxo_id ON utxo_leases +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_lease_wallet_consistency(); diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql new file mode 100644 index 0000000000..3def684c18 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_disconnect_transactions_before_block_delete; +DROP TABLE IF EXISTS transactions; diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql new file mode 100644 index 0000000000..e6c9194b82 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql @@ -0,0 +1,170 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Transactions table stores wallet-scoped blockchain transactions and their +-- wallet-relative validity/confirmation state. +CREATE TABLE transactions ( + -- Reference to the wallet that owns this transaction row. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the transaction, primary key (rowid-backed). + id INTEGER PRIMARY KEY, + + -- Transaction hash (txid) (32 bytes). Unique per wallet. + tx_hash BLOB NOT NULL CHECK (length(tx_hash) = 32), + + -- Raw serialized transaction bytes. + -- + -- NOTE: Hot-path queries (balance/coin selection) SHOULD avoid selecting + -- this column. + raw_tx BLOB NOT NULL, + + -- Confirmation state: + -- NULL = Unconfirmed (mempool) + -- INT = Confirmed (mined) + -- + -- ON DELETE SET NULL: If a block is reorged, the transaction becomes + -- unconfirmed. + block_height INTEGER REFERENCES blocks (block_height) ON DELETE SET NULL, + + -- Validity state (soft deletion). + -- + -- Store the status code inline instead of via a lookup table because this + -- enum is tiny, closed, and appears on hot-path predicates/indexes. + -- + -- Status codes: + -- 0 = pending + -- 1 = published + -- 2 = replaced + -- 3 = failed + -- 4 = orphaned + tx_status INTEGER NOT NULL, + + -- Absolute wall clock time, supplied by the caller and stored in UTC + -- without timezone info. + -- + -- NOTE: There is intentionally no DEFAULT current_timestamp here because + -- import/recovery flows may need to preserve the wallet-observed receive + -- time instead of the row insertion time. + received_time DATETIME NOT NULL, + + -- Whether this transaction is a coinbase transaction. + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Optional user-provided label. Empty string means "no label". + -- SQLite treats VARCHAR(N) like TEXT, so we use TEXT plus an explicit + -- CHECK to enforce the shared 500-character limit. + tx_label TEXT NOT NULL DEFAULT '' + CHECK (length(tx_label) <= 500), + + -- Secondary unique constraint used as the referenced key for wallet-scoped + -- child relations such as utxos and tx_replacements. + CONSTRAINT uidx_transactions_wallet_id_id UNIQUE (wallet_id, id), + + -- Wallet-scoped uniqueness lets different wallets record the same network + -- txid independently while keeping every child lookup anchored to one + -- wallet. + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + + -- Keep the persisted validity state closed over the finite set of states + -- the store knows how to interpret and transition between. + CONSTRAINT valid_status CHECK ( + tx_status IN (0, 1, 2, 3, 4) + ), + + -- Non-coinbase transactions cannot enter the orphaned state. That state is + -- reserved for coinbase rows that were disconnected from the best chain. + CONSTRAINT check_orphaned_coinbase_only CHECK ( + tx_status != 4 OR is_coinbase + ), + + -- A transaction attached to a block is treated as confirmed wallet history. + -- For confirmed rows, the only valid status is `published`; every other + -- status represents either blockless local state or disconnected history. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR tx_status = 1 + ), + + -- Coinbase transactions cannot exist in the local-only pre-broadcast state + -- because they are created by mining, not by wallet authorship. + CONSTRAINT check_coinbase_not_pending CHECK ( + NOT (is_coinbase AND tx_status = 0) + ), + + -- Coinbase rows may only be recorded in their mined form or in the + -- orphaned form produced by a disconnect/reorg transition. + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase + OR (block_height IS NOT NULL AND tx_status = 1) + OR (block_height IS NULL AND tx_status = 4) + ) +); + +-- Optimization for live unconfirmed transaction lookups. +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL AND tx_status IN (0, 1); + +-- Optimization for wallet-scoped joins into the live transaction set. +CREATE INDEX idx_transactions_live_by_wallet +ON transactions (wallet_id, id) +WHERE tx_status IN (0, 1); + +-- Optimization for wallet-scoped blockless history reads ordered by newest +-- receive time first. +CREATE INDEX idx_transactions_blockless_history +ON transactions (wallet_id, received_time DESC, id DESC) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries. +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for rollback/disconnect paths that only know the confirmed block +-- height and then fan out to affected wallet rows. +CREATE INDEX idx_transactions_by_confirmed_height +ON transactions (block_height, wallet_id, id) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries. +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Reorg handling for coinbase transactions. +-- +-- SQLite evaluates this disconnect path differently from PostgreSQL. The block +-- delete would clear child `transactions.block_height` values through the FK +-- action, but SQLite checks row validity before a child-side AFTER UPDATE hook +-- can repair coinbase status. We therefore rewrite affected child rows from the +-- parent-table delete path instead. +-- +-- The invariant is the same as PostgreSQL: +-- - coinbase + confirmed block => status = 1 (`published`) +-- - coinbase + no block => status = 4 (`orphaned`) +-- +-- The BEFORE DELETE trigger on blocks updates child rows into their final +-- disconnected shape before the parent block row is removed. +-- +-- NOTE: Generic per-row block disconnects are intentionally unsupported here. +-- Wallet code should use dedicated rollback/rewind operations instead of +-- `UPDATE transactions SET block_height = NULL`. +CREATE TRIGGER trg_disconnect_transactions_before_block_delete +BEFORE DELETE ON blocks +FOR EACH ROW +BEGIN + -- Rewrite every child transaction that points at the deleted block height. + UPDATE transactions + SET + -- Coinbase rows must become orphaned in the same UPDATE that clears + -- block_height so the CHECK constraints never observe an impossible + -- "coinbase + NULL block + published" combination. + tx_status = CASE + WHEN is_coinbase THEN 4 + -- Ordinary transactions just become blockless; their non-orphaned + -- status is preserved for later rollback/invalidation handling. + ELSE tx_status + END, + block_height = NULL + WHERE block_height = old.block_height; +END; diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql b/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql new file mode 100644 index 0000000000..3803684116 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS utxos; diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql b/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql new file mode 100644 index 0000000000..4b78130819 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql @@ -0,0 +1,126 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- UTXOs table stores wallet-owned credits (spent and unspent). +CREATE TABLE utxos ( + -- DB ID of the UTXO, primary key (rowid-backed). + id INTEGER PRIMARY KEY, + + -- Creation outpoint (tx_id + output_index). + tx_id INTEGER NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + -- Output amount in satoshis. + amount INTEGER NOT NULL CHECK (amount >= 0), + + -- Reference to the address record that owns the output. + -- + -- NOTE: The address-manager schema does not expose wallet_id on addresses, + -- so ownership is derived via addresses -> accounts -> key_scopes and + -- enforced by trigger below. + address_id INTEGER NOT NULL REFERENCES addresses (id) ON DELETE RESTRICT, + + -- Spending input (when spent). + spent_by_tx_id INTEGER, + spent_input_index INTEGER CHECK ( + spent_input_index IS NULL OR spent_input_index >= 0 + ), + + -- The creating transaction anchors the outpoint to one wallet-scoped + -- transaction history. + CONSTRAINT fkey_utxos_tx FOREIGN KEY (tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- Manual pruning note: + -- The reference ADR uses ON DELETE SET NULL here to restore spendability + -- when the spending transaction is physically deleted. This repository + -- uses ON DELETE RESTRICT and requires an explicit pruning operation that + -- clears spent_by_* first. + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (spent_by_tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- spent_by_tx_id and spent_input_index together model one logical pointer + -- to the spending input, so they must transition between NULL and non-NULL + -- as a pair. + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) + OR (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + -- Each wallet-local transaction records a given network outpoint at most + -- once, which keeps credit insertion idempotent and lets outpoint lookups + -- resolve to one row. + CONSTRAINT uidx_utxos_outpoint UNIQUE (tx_id, output_index) +); + +-- Optimization for balance queries (index-only scan). +CREATE INDEX idx_utxos_unspent +ON utxos (tx_id, amount, output_index) +WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent). +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction. +CREATE INDEX idx_utxos_spent_by ON utxos (spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction. +CREATE INDEX idx_utxos_by_tx ON utxos (tx_id); + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_insert +BEFORE INSERT ON utxos +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo creating tx wallet and address wallet must match') + WHERE ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ) != ( + SELECT ks.wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = new.address_id + ); + + SELECT raise(ABORT, 'utxo spending tx wallet must match creating tx wallet') + WHERE new.spent_by_tx_id IS NOT NULL AND ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.spent_by_tx_id + ) != ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ); +END; + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_update +BEFORE UPDATE OF tx_id, address_id, spent_by_tx_id ON utxos +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo creating tx wallet and address wallet must match') + WHERE ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ) != ( + SELECT ks.wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = new.address_id + ); + + SELECT raise(ABORT, 'utxo spending tx wallet must match creating tx wallet') + WHERE new.spent_by_tx_id IS NOT NULL AND ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.spent_by_tx_id + ) != ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ); +END; diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql new file mode 100644 index 0000000000..19f69ebbc9 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS tx_replacements; diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql new file mode 100644 index 0000000000..c074b43d45 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql @@ -0,0 +1,55 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- tx_replacements stores the audit edges between replaced and replacement +-- wallet-scoped transactions. +CREATE TABLE tx_replacements ( + -- Reference to the wallet that owns this replacement edge. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the replacement edge. + -- + -- SQLite only auto-generates row IDs for a single-column INTEGER PRIMARY + -- KEY, so this branch uses a rowid-backed key here and keeps the + -- wallet-scoped `(wallet_id, id)` pair unique through a separate + -- constraint. + id INTEGER PRIMARY KEY, + + -- The direct victim transaction in the replacement pair. + replaced_tx_id INTEGER NOT NULL, + + -- The direct winner transaction in the replacement pair. + replacement_tx_id INTEGER NOT NULL, + + -- Creation timestamp used for replacement-edge traversal ordering. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Secondary unique constraint used for wallet-scoped foreign keys. + CONSTRAINT uidx_tx_replacements_wallet_id_id UNIQUE (wallet_id, id), + + -- The audit edge must stay inside one wallet-scoped transaction graph. + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY ( + wallet_id, replaced_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY ( + wallet_id, replacement_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + + -- A transaction cannot replace itself. + CONSTRAINT check_not_self_replacement CHECK ( + replaced_tx_id != replacement_tx_id + ), + + -- One directed replacement edge may only be recorded once. + CONSTRAINT uidx_tx_replacements_edge UNIQUE ( + wallet_id, replaced_tx_id, replacement_tx_id + ) +); + +-- Optimization for traversing direct victims from a winner. +CREATE INDEX idx_tx_replacements_by_replacement +ON tx_replacements (wallet_id, replacement_tx_id, created_at, id); + +-- Optimization for traversing direct winners from a victim. +CREATE INDEX idx_tx_replacements_by_replaced +ON tx_replacements (wallet_id, replaced_tx_id, created_at, id); diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql new file mode 100644 index 0000000000..9752486ade --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS utxo_leases; diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql new file mode 100644 index 0000000000..2917ef2905 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql @@ -0,0 +1,53 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- utxo_leases stores transient application-level locks over wallet-owned UTXOs. +CREATE TABLE utxo_leases ( + -- Reference to the wallet that owns the leased UTXO. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- The leased UTXO row. + utxo_id INTEGER PRIMARY KEY, + + -- Caller-provided lock ID. It must stay fixed-width so lease ownership can + -- be compared without decoding application-specific payloads. + lock_id BLOB NOT NULL CHECK (length(lock_id) = 32), + + -- UTC-normalized lease expiration timestamp. + expires_at TIMESTAMP NOT NULL, + + -- The leased output must exist in the UTXO set. Wallet consistency is + -- enforced by trigger below. + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (utxo_id) + REFERENCES utxos (id) ON DELETE CASCADE +); + +-- Optimization for wallet-scoped lease cleanup and active-lease scans. +CREATE INDEX idx_utxo_leases_wallet_expires_at +ON utxo_leases (wallet_id, expires_at); + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_insert +BEFORE INSERT ON utxo_leases +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo lease wallet must match leased utxo wallet') + WHERE ( + SELECT t.wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = new.utxo_id + ) != new.wallet_id; +END; + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_update +BEFORE UPDATE OF wallet_id, utxo_id ON utxo_leases +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo lease wallet must match leased utxo wallet') + WHERE ( + SELECT t.wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = new.utxo_id + ) != new.wallet_id; +END; From 7e9b31e5d869994676d07cbb15baa5978ed0f8b2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:43 +0800 Subject: [PATCH 142/695] wallet: add sqlc queries for transactions --- .../db/queries/postgres/transactions.sql | 274 ++++++++++++++++++ .../internal/db/queries/postgres/wallets.sql | 6 +- .../db/queries/sqlite/transactions.sql | 273 +++++++++++++++++ 3 files changed, 550 insertions(+), 3 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/transactions.sql create mode 100644 wallet/internal/db/queries/sqlite/transactions.sql diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql new file mode 100644 index 0000000000..0f41958652 --- /dev/null +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -0,0 +1,274 @@ +-- name: InsertTransaction :one +-- Inserts a wallet-scoped transaction row and returns its database ID. +-- +-- How: +-- - Writes only the transactions table. +-- - Expects the caller to have already resolved wallet scope and any optional +-- block reference. +-- - Expects the caller to supply the initial status explicitly so unmined rows +-- do not have to guess between `pending` and `published`. +-- Performance: +-- - Single-row insert. The cost is dominated by the wallet/hash uniqueness +-- checks and any optional block foreign-key validation. +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING id; + +-- name: GetTransactionMetaByHash :one +-- Retrieves the primary key and lightweight transaction metadata. +-- +-- How: +-- - Reads only the transactions table because callers only need row identity +-- plus lightweight status/label fields. +-- Performance: +-- - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = $1 AND tx_hash = $2; + +-- name: GetTransactionByHash :one +-- Retrieves the full transaction row along with optional block metadata. +-- +-- How: +-- - Looks up the transaction by `(wallet_id, tx_hash)`. +-- - LEFT JOINs blocks on `block_height` so the same query handles mined and +-- unmined rows. +-- Performance: +-- - The unique transaction lookup limits the join fanout to at most one block +-- row. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = $1 AND t.tx_hash = $2; + +-- name: ListUnminedTransactions :many +-- Lists all unconfirmed transactions for a wallet. +-- +-- How: +-- - Reads from transactions only and filters on blockless rows that are still +-- in a live unconfirmed state (`pending` or `published`). +-- - Excludes orphaned/replaced/failed history so rollback-produced coinbase +-- rows do not reappear as mempool transactions. +-- - Returns typed NULL block metadata explicitly because live unmined rows have +-- no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the unmined row shape aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated blockless-history index while the more selective +-- live-only partial index stays available for conflict paths. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC; + +-- name: ListTransactionsByHeightRange :many +-- Lists all confirmed transactions for a wallet in the provided height range. +-- +-- How: +-- - Reads transactions in a wallet-scoped block-height range. +-- - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +-- and timestamp for confirmed rows. +-- Performance: +-- - The `(wallet_id, block_height)` index bounds the scan before the single-row +-- block join. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.block_height BETWEEN + sqlc.arg('start_height')::INTEGER + AND sqlc.arg('end_height')::INTEGER +ORDER BY t.block_height, t.id; + +-- name: UpdateTransactionLabelByHash :execrows +-- Updates only the user-visible transaction label. +-- +-- How: +-- - Leaves block assignment and status untouched. +-- - Exists for user-facing metadata edits only; wallet-internal state +-- transitions use dedicated helper queries. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET tx_label = sqlc.arg('label') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash'); + +-- name: ConfirmUnminedTransactionByHash :execrows +-- Attaches a confirming block to one existing live unmined transaction row. +-- +-- How: +-- - Updates only rows that are still blockless and live (`pending` or +-- `published`). +-- - Leaves user-visible metadata such as labels untouched so confirmation can +-- reuse the original transaction row instead of reinserting it. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET + block_height = sqlc.arg('block_height')::INTEGER, + tx_status = 1 +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash') + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: UpdateTransactionStatusByIDs :execrows +-- Updates the wallet-relative status for a set of transaction row IDs. +-- +-- How: +-- - Exists for wallet-internal replacement and invalidation flows after the +-- caller has already identified the affected rows. +-- - Leaves block assignment untouched; rollback/disconnect continues to use the +-- dedicated rewind helpers below. +-- Performance: +-- - Restricts by wallet scope first, then matches only the provided ID set. +UPDATE transactions +SET tx_status = sqlc.arg('status') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND id = any(sqlc.arg('tx_ids')::BIGINT []); + +-- name: DeleteUnminedTransactionByHash :execrows +-- Deletes an unconfirmed transaction row. +-- +-- How: +-- - Deletes only rows whose `block_height` is still NULL and whose status is +-- still in a live unconfirmed state (`pending` or `published`). +-- - Preserves orphaned/replaced/failed history; those rows must remain visible +-- for audit/reorg handling instead of being treated as ordinary mempool data. +-- - The caller must delete or restore dependent UTXO rows first. +-- Performance: +-- - Targets at most one row by `(wallet_id, tx_hash)`. +DELETE FROM transactions +WHERE + wallet_id = $1 + AND tx_hash = $2 + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: ListRollbackCoinbaseRoots :many +-- Lists wallet-scoped coinbase transaction hashes at or above the rollback +-- boundary that seed descendant invalidation. +-- +-- How: +-- - Reads only confirmed coinbase rows at or above the rollback boundary. +-- - Returns wallet scope alongside each tx hash so callers can treat these +-- coinbase transactions as rollback roots when invalidating now-dead +-- descendants inside the same rollback transaction. +-- - This is a rollback-specific helper, not a generic "coinbase txs from one +-- block" listing query. +-- Performance: +-- - Uses the block-height index to bound the scan to the rollback range. +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= sqlc.arg('rollback_height')::INTEGER + AND is_coinbase +ORDER BY wallet_id, id; + +-- name: RewindWalletSyncStateHeightsForRollback :execrows +-- Rewrites wallet sync-state heights so they stop referencing blocks that are +-- about to be deleted during RollbackToBlock. +-- +-- How: +-- - Updates wallet_sync_states directly without joining other tables. +-- - Rewrites both synced_height and birthday_height in one statement so the +-- subsequent block delete does not violate `ON DELETE RESTRICT`. +-- - Example: if `rollback_height = 195`, then any `synced_height` or +-- `birthday_height` at 195 or above rewinds to `new_height = 194`. +-- - If rollback starts from height 0, callers pass `new_height = NULL` so the +-- sync state no longer points at any surviving block row. +-- Performance: +-- - Touches only wallet_sync_states rows whose heights are at or above the +-- rollback boundary. +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= sqlc.arg('rollback_height')::INTEGER + THEN sqlc.narg('new_height') + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= sqlc.arg('rollback_height')::INTEGER + THEN sqlc.narg('new_height') + ELSE birthday_height + END, + updated_at = current_timestamp AT TIME ZONE 'UTC' +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= sqlc.arg('rollback_height')::INTEGER + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= sqlc.arg('rollback_height')::INTEGER + ); + +-- name: DeleteBlocksAtOrAboveHeight :execrows +-- Deletes blocks at and after the provided height. +-- +-- How: +-- - Deletes directly from blocks by the natural height key. +-- - Relies on FK/trigger side effects to null transaction block references and +-- orphan coinbase rows. +-- Performance: +-- - Executes as a range delete over the block-height primary key. +DELETE FROM blocks +WHERE block_height >= $1; diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index b1033fc7b2..58b6126a6a 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -20,7 +20,7 @@ INSERT INTO wallet_sync_states ( birthday_timestamp, updated_at ) VALUES ( - $1, $2, $3, $4, current_timestamp + $1, $2, $3, $4, current_timestamp AT TIME ZONE 'UTC' ); -- name: UpdateWalletSyncState :execrows @@ -35,8 +35,8 @@ SET -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), - -- Always update timestamp to current database time. - updated_at = current_timestamp + -- Always update timestamp to current database time in UTC. + updated_at = current_timestamp AT TIME ZONE 'UTC' WHERE wallet_id = $1; diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql new file mode 100644 index 0000000000..4ae01419b0 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -0,0 +1,273 @@ +-- name: InsertTransaction :one +-- Inserts a wallet-scoped transaction row and returns its database ID. +-- +-- How: +-- - Writes only the transactions table. +-- - Expects the caller to have already resolved wallet scope and any optional +-- block reference. +-- - Expects the caller to supply the initial status explicitly so unmined rows +-- do not have to guess between `pending` and `published`. +-- Performance: +-- - Single-row insert. The cost is dominated by the wallet/hash uniqueness +-- checks and any optional block foreign-key validation. +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: GetTransactionMetaByHash :one +-- Retrieves the primary key and lightweight transaction metadata. +-- +-- How: +-- - Reads only the transactions table because callers only need row identity +-- plus lightweight status/label fields. +-- Performance: +-- - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = ? AND tx_hash = ?; + +-- name: GetTransactionByHash :one +-- Retrieves the full transaction row along with optional block metadata. +-- +-- How: +-- - Looks up the transaction by `(wallet_id, tx_hash)`. +-- - LEFT JOINs blocks on `block_height` so the same query handles mined and +-- unmined rows. +-- Performance: +-- - The unique transaction lookup limits the join fanout to at most one block +-- row. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = ? AND t.tx_hash = ?; + +-- name: ListUnminedTransactions :many +-- Lists all unconfirmed transactions for a wallet. +-- +-- How: +-- - Reads from transactions only and filters on blockless rows that are still +-- in a live unconfirmed state (`pending` or `published`). +-- - Excludes orphaned/replaced/failed history so rollback-produced coinbase +-- rows do not reappear as mempool transactions. +-- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +-- so the unmined row shape stays aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated blockless-history index while the more selective +-- live-only partial index stays available for conflict paths. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC; + +-- name: ListTransactionsByHeightRange :many +-- Lists all confirmed transactions for a wallet in the provided height range. +-- +-- How: +-- - Reads transactions in a wallet-scoped block-height range. +-- - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +-- and timestamp for confirmed rows. +-- Performance: +-- - The `(wallet_id, block_height)` index bounds the scan before the single-row +-- block join. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.block_height >= cast(sqlc.arg('start_height') AS INTEGER) + AND t.block_height <= cast(sqlc.arg('end_height') AS INTEGER) +ORDER BY t.block_height, t.id; + +-- name: UpdateTransactionLabelByHash :execrows +-- Updates only the user-visible transaction label. +-- +-- How: +-- - Leaves block assignment and status untouched. +-- - Exists for user-facing metadata edits only; wallet-internal state +-- transitions use dedicated helper queries. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET tx_label = sqlc.arg('label') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash'); + +-- name: ConfirmUnminedTransactionByHash :execrows +-- Attaches a confirming block to one existing live unmined transaction row. +-- +-- How: +-- - Updates only rows that are still blockless and live (`pending` or +-- `published`). +-- - Leaves user-visible metadata such as labels untouched so confirmation can +-- reuse the original transaction row instead of reinserting it. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET + block_height = cast(sqlc.arg('block_height') AS INTEGER), + tx_status = 1 +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash') + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: UpdateTransactionStatusByIDs :execrows +-- Updates the wallet-relative status for a set of transaction row IDs. +-- +-- How: +-- - Exists for wallet-internal replacement and invalidation flows after the +-- caller has already identified the affected rows. +-- - Leaves block assignment untouched; rollback/disconnect continues to use the +-- dedicated rewind helpers below. +-- Performance: +-- - Restricts by wallet scope first, then matches only the provided ID set. +UPDATE transactions +SET tx_status = sqlc.arg('status') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND id IN (sqlc.slice('tx_ids')); + +-- name: DeleteUnminedTransactionByHash :execrows +-- Deletes an unconfirmed transaction row. +-- +-- How: +-- - Deletes only rows whose `block_height` is still NULL and whose status is +-- still in a live unconfirmed state (`pending` or `published`). +-- - Preserves orphaned/replaced/failed history; those rows must remain visible +-- for audit/reorg handling instead of being treated as ordinary mempool data. +-- - The caller must delete or restore dependent UTXO rows first. +-- Performance: +-- - Targets at most one row by `(wallet_id, tx_hash)`. +DELETE FROM transactions +WHERE + wallet_id = ? + AND tx_hash = ? + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: ListRollbackCoinbaseRoots :many +-- Lists wallet-scoped coinbase transaction hashes at or above the rollback +-- boundary that seed descendant invalidation. +-- +-- How: +-- - Reads only confirmed coinbase rows at or above the rollback boundary. +-- - Returns wallet scope alongside each tx hash so callers can treat these +-- coinbase transactions as rollback roots when invalidating now-dead +-- descendants inside the same rollback transaction. +-- - This is a rollback-specific helper, not a generic "coinbase txs from one +-- block" listing query. +-- Performance: +-- - Uses the block-height index to bound the scan to the rollback range. +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + AND is_coinbase +ORDER BY wallet_id, id; + +-- name: RewindWalletSyncStateHeightsForRollback :execrows +-- Rewrites wallet sync-state heights so they stop referencing blocks that are +-- about to be deleted during RollbackToBlock. +-- +-- How: +-- - Updates wallet_sync_states directly without joining other tables. +-- - Rewrites both synced_height and birthday_height in one statement so the +-- subsequent block delete does not violate `ON DELETE RESTRICT`. +-- - Example: if `rollback_height = 195`, then any `synced_height` or +-- `birthday_height` at 195 or above rewinds to `new_height = 194`. +-- - If rollback starts from height 0, callers pass `new_height = NULL` so the +-- sync state no longer points at any surviving block row. +-- Performance: +-- - Touches only wallet_sync_states rows whose heights are at or above the +-- rollback boundary. +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + THEN sqlc.narg('new_height') + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + THEN sqlc.narg('new_height') + ELSE birthday_height + END, + updated_at = current_timestamp +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + ); + +-- name: DeleteBlocksAtOrAboveHeight :execrows +-- Deletes blocks at and after the provided height. +-- +-- How: +-- - Deletes directly from blocks by the natural height key. +-- - Relies on FK/trigger side effects to null transaction block references and +-- orphan coinbase rows. +-- Performance: +-- - Executes as a range delete over the block-height primary key. +DELETE FROM blocks +WHERE block_height >= ?; From a9d660f4483b3edb32a0222d08cc0be7716f6076 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:57 +0800 Subject: [PATCH 143/695] wallet: add sqlc queries for utxos --- wallet/internal/db/queries/postgres/utxos.sql | 370 +++++++++++++++++ wallet/internal/db/queries/sqlite/utxos.sql | 375 ++++++++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/utxos.sql create mode 100644 wallet/internal/db/queries/sqlite/utxos.sql diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql new file mode 100644 index 0000000000..1dc4a0c06f --- /dev/null +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -0,0 +1,370 @@ +-- name: InsertUtxo :one +-- Inserts a new UTXO row and returns its database ID. +-- +-- How: +-- - Writes only the utxos table using already-resolved transaction and address +-- IDs. +-- - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +-- the provided address ID belongs to the same wallet. +-- Performance: +-- - Single-row insert. The main cost is the wallet-ownership validation join +-- plus FK and uniqueness checks. +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + sqlc.arg('output_index') AS output_index, + sqlc.arg('amount') AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = sqlc.arg('tx_id') + AND t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND + a.id = sqlc.arg('address_id') + AND ks.wallet_id = sqlc.arg('wallet_id') +RETURNING id; + +-- name: GetUtxoIDByOutpoint :one +-- Retrieves the database ID for a current UTXO by its outpoint. +-- +-- How: +-- - Joins transactions on `id` so callers can address a UTXO by +-- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +-- - Restricts the result to unspent outputs whose parent transaction is still +-- in a live state (`pending` or `published`). +-- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +-- rows whose credited address does not actually belong to the wallet. +-- - Exists separately from GetUtxoByOutpoint because mutation helpers often +-- need the stable internal UTXO row ID without reading the full public UTXO +-- payload. +-- Performance: +-- - Uses the wallet-scoped transaction hash lookup first, then narrows to the +-- unique `(tx_id, output_index)` outpoint. +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: GetUtxoByOutpoint :one +-- Retrieves a single unspent UTXO by its outpoint. +-- +-- How: +-- - Joins utxos -> transactions on `tx_id` to resolve the outpoint +-- from tx hash plus output index. +-- - Joins addresses -> accounts -> key_scopes so the read path reasserts that +-- the credited address belongs to the requested wallet. +-- - Returns leased and unleased outputs alike because leasing affects coin +-- selection, not whether the UTXO exists. +-- - Treats outputs from both live unconfirmed parent states (`pending` and +-- `published`) as part of the wallet's current UTXO set. +-- Performance: +-- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +-- join fanout to at most one candidate output. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: ListUtxos :many +-- Lists unspent UTXOs that match the provided filters. +-- +-- How: +-- - Starts from utxos and joins transactions for tx metadata plus +-- wallet_sync_states for confirmation math. +-- - Joins addresses to return the required script_pub_key, then reuses +-- addresses -> accounts -> key_scopes so account filtering and wallet +-- ownership checks happen in the same read. +-- - Returns leased outputs too because the API models leases separately from +-- UTXO existence. +-- - Includes outputs whose parent transaction is still in a live unconfirmed +-- state (`pending` or `published`). +-- - Intentionally does not enforce coinbase maturity because this query models +-- wallet-owned UTXO existence rather than a strictly spendable subset. +-- Performance: +-- - Restricts first by wallet, spend state, and transaction status. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +-- - Treats min/max confirmations as optional filters so callers can +-- distinguish "not set" from an explicit zero-conf request. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number')::BIGINT IS NULL + OR acc.account_number = sqlc.narg('account_number')::BIGINT + ) + AND ( + sqlc.narg('min_confirms')::INTEGER IS NULL + OR sqlc.narg('min_confirms')::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms')::INTEGER + ) + AND ( + sqlc.narg('max_confirms')::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms')::INTEGER + ) +ORDER BY u.amount, t.tx_hash, u.output_index; + +-- name: Balance :one +-- Returns the total and locked value represented by the wallet's current +-- unspent UTXO set. +-- +-- How: +-- - Starts from wallet-scoped unspent outputs and rejoins transactions plus +-- wallet_sync_states for confirmation math. +-- - Rejoins addresses -> accounts -> key_scopes so ownership validation and +-- optional account filtering stay in one read. +-- - Applies optional confirmation-range and coinbase-maturity policy directly +-- inside the aggregate query so callers can request factual or policy-shaped +-- balance reads through one public method. +-- - Returns both the total matching value and the locked subset covered by +-- active leases after the same filters are applied. +-- Performance: +-- - Executes as one aggregate over wallet-scoped live outputs. +-- - Uses a filtered aggregate over active leases rather than issuing a second +-- query for the locked subset. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +SELECT + (coalesce(sum(u.amount), 0))::BIGINT AS total_balance, + (coalesce( + sum(u.amount) FILTER ( + WHERE EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > sqlc.arg('now_utc')::TIMESTAMP + ) + ), + 0 + ))::BIGINT AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number')::BIGINT IS NULL + OR acc.account_number = sqlc.narg('account_number')::BIGINT + ) + AND ( + sqlc.narg('min_confirms')::INTEGER IS NULL + OR sqlc.narg('min_confirms')::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms')::INTEGER + ) + AND ( + sqlc.narg('max_confirms')::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms')::INTEGER + ) + AND ( + sqlc.narg('coinbase_maturity')::INTEGER IS NULL + OR sqlc.narg('coinbase_maturity')::INTEGER = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('coinbase_maturity')::INTEGER + ); + +-- name: ListSpendingTxIDsByParentTxID :many +-- Lists direct child transaction IDs for one parent transaction ID. +-- +-- How: +-- - Reads the spend edges already materialized on utxos through +-- `(tx_id, spent_by_tx_id)`. +-- - Returns only direct children; callers that need full descendant walks should +-- traverse this query iteratively in application code. +-- Performance: +-- - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +-- to one wallet-scoped parent. +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = $2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +ORDER BY u.spent_by_tx_id; + +-- name: GetUtxoSpendByOutpoint :one +-- Returns the current spend edge for one wallet-owned outpoint. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +-- considers outputs whose parent status is `pending` or `published`. +-- - Returns the nullable `spent_by_tx_id` column so callers can distinguish +-- between an external/dead parent and a wallet-owned conflict. +-- Performance: +-- - Targets one wallet-scoped outpoint through the unique `(tx_id, +-- output_index)` key after the parent hash lookup. +SELECT u.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status IN (0, 1); + +-- name: MarkUtxoSpent :execrows +-- Marks a wallet-owned UTXO as spent by a transaction. +-- +-- How: +-- - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +-- the statement so callers can update by network outpoint. +-- - Requires the parent transaction status to be `pending` or `published` +-- before a child spend edge can attach. +-- - Only changes rows that are currently unspent or already point at the same +-- `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +-- without allowing a caller to silently rewrite which input spent the UTXO. +-- Performance: +-- - Targets one outpoint in one wallet; the subquery uses the unique +-- wallet-scoped tx hash lookup. +UPDATE utxos AS u +SET + spent_by_tx_id = $4, + spent_input_index = $5 +WHERE + u.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND t.tx_status IN (0, 1) + ) + AND u.output_index = $3 + AND ( + (u.spent_by_tx_id IS NULL AND u.spent_input_index IS NULL) + OR (u.spent_by_tx_id = $4 AND u.spent_input_index = $5) + ); + +-- name: ClearUtxosSpentByTxID :execrows +-- Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +-- +-- How: +-- - Resets both spent columns together so the logical spend pointer remains +-- internally consistent. +-- Performance: +-- - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +-- ownership through the creating transaction. +UPDATE utxos AS u +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + u.spent_by_tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ); + +-- name: DeleteUtxosByTxID :execrows +-- Deletes all UTXO rows created by the provided transaction ID. +-- +-- How: +-- - Removes outputs by the parent transaction's internal ID after callers have +-- already decided the transaction row itself may be deleted. +-- Performance: +-- - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +-- outputs, then rechecks wallet ownership through the parent transaction. +DELETE FROM utxos AS u +WHERE + u.tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ); diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql new file mode 100644 index 0000000000..403695abe6 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -0,0 +1,375 @@ +-- name: InsertUtxo :one +-- Inserts a new UTXO row and returns its database ID. +-- +-- How: +-- - Writes only the utxos table using already-resolved transaction and address +-- IDs. +-- - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +-- the provided address ID belongs to the same wallet. +-- Performance: +-- - Single-row insert. The main cost is the wallet-ownership validation join +-- plus FK and uniqueness checks. +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + sqlc.arg('output_index') AS output_index, + sqlc.arg('amount') AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = sqlc.arg('tx_id') + AND t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND + a.id = sqlc.arg('address_id') + AND ks.wallet_id = sqlc.arg('wallet_id') +RETURNING id; + +-- name: GetUtxoIDByOutpoint :one +-- Retrieves the database ID for a current UTXO by its outpoint. +-- +-- How: +-- - Joins transactions on `id` so callers can address a UTXO by +-- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +-- - Restricts the result to unspent outputs whose parent transaction is still +-- in a live state (`pending` or `published`). +-- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +-- rows whose credited address does not actually belong to the wallet. +-- - Exists separately from GetUtxoByOutpoint because mutation helpers often +-- need the stable internal UTXO row ID without reading the full public UTXO +-- payload. +-- Performance: +-- - Uses the wallet-scoped transaction hash lookup first, then narrows to the +-- unique `(tx_id, output_index)` outpoint. +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: GetUtxoByOutpoint :one +-- Retrieves a single unspent UTXO by its outpoint. +-- +-- How: +-- - Joins utxos -> transactions on `tx_id` to resolve the outpoint +-- from tx hash plus output index. +-- - Joins addresses -> accounts -> key_scopes so the read path reasserts that +-- the credited address belongs to the requested wallet. +-- - Returns leased and unleased outputs alike because leasing affects coin +-- selection, not whether the UTXO exists. +-- - Treats outputs from both live unconfirmed parent states (`pending` and +-- `published`) as part of the wallet's current UTXO set. +-- Performance: +-- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +-- join fanout to at most one candidate output. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: ListUtxos :many +-- Lists unspent UTXOs that match the provided filters. +-- +-- How: +-- - Starts from utxos and joins transactions for tx metadata plus +-- wallet_sync_states for confirmation math. +-- - Joins addresses to return the required script_pub_key, then reuses +-- addresses -> accounts -> key_scopes so account filtering and wallet +-- ownership checks happen in the same read. +-- - Returns leased outputs too because the API models leases separately from +-- UTXO existence. +-- - Includes outputs whose parent transaction is still in a live unconfirmed +-- state (`pending` or `published`). +-- - Intentionally does not enforce coinbase maturity because this query models +-- wallet-owned UTXO existence rather than a strictly spendable subset. +-- Performance: +-- - Restricts first by wallet, spend state, and transaction status. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +-- - Treats min/max confirmations as optional filters so callers can +-- distinguish "not set" from an explicit zero-conf request. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number') IS NULL + OR acc.account_number = sqlc.narg('account_number') + ) + AND ( + sqlc.narg('min_confirms') IS NULL + OR sqlc.narg('min_confirms') = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms') + ) + AND ( + sqlc.narg('max_confirms') IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms') + ) +ORDER BY u.amount, t.tx_hash, u.output_index; + +-- name: Balance :one +-- Returns the total and locked value represented by the wallet's current +-- unspent UTXO set. +-- +-- How: +-- - Starts from wallet-scoped unspent outputs and rejoins transactions plus +-- wallet_sync_states for confirmation math. +-- - Rejoins addresses -> accounts -> key_scopes so ownership validation and +-- optional account filtering stay in one read. +-- - Applies optional confirmation-range and coinbase-maturity policy directly +-- inside the aggregate query so callers can request factual or policy-shaped +-- balance reads through one public method. +-- - Returns both the total matching value and the locked subset covered by +-- active leases after the same filters are applied. +-- Performance: +-- - Executes as one aggregate over wallet-scoped live outputs. +-- - Uses a filtered aggregate over active leases rather than issuing a second +-- query for the locked subset. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +SELECT + cast(coalesce(sum(u.amount), 0) AS INTEGER) AS total_balance, + cast( + coalesce( + sum( + CASE + WHEN EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > sqlc.arg('now_utc') + ) THEN u.amount + ELSE 0 + END + ), + 0 + ) AS INTEGER + ) AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number') IS NULL + OR acc.account_number = sqlc.narg('account_number') + ) + AND ( + sqlc.narg('min_confirms') IS NULL + OR sqlc.narg('min_confirms') = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms') + ) + AND ( + sqlc.narg('max_confirms') IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms') + ) + AND ( + sqlc.narg('coinbase_maturity') IS NULL + OR sqlc.narg('coinbase_maturity') = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('coinbase_maturity') + ); + +-- name: ListSpendingTxIDsByParentTxID :many +-- Lists direct child transaction IDs for one parent transaction ID. +-- +-- How: +-- - Reads the spend edges already materialized on utxos through +-- `(tx_id, spent_by_tx_id)`. +-- - Returns only direct children; callers that need full descendant walks should +-- traverse this query iteratively in application code. +-- Performance: +-- - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +-- to one wallet-scoped parent. +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = ?2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = ?1 + ) +ORDER BY u.spent_by_tx_id; + +-- name: GetUtxoSpendByOutpoint :one +-- Returns the current spend edge for one wallet-owned outpoint. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +-- considers outputs whose parent status is `pending` or `published`. +-- - Returns the nullable `spent_by_tx_id` column so callers can distinguish +-- between an external/dead parent and a wallet-owned conflict. +-- Performance: +-- - Targets one wallet-scoped outpoint through the unique `(tx_id, +-- output_index)` key after the parent hash lookup. +SELECT utxos.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos ON t.id = utxos.tx_id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status IN (0, 1); + +-- name: MarkUtxoSpent :execrows +-- Marks a wallet-owned UTXO as spent by a transaction. +-- +-- How: +-- - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +-- the statement so callers can update by network outpoint. +-- - Requires the parent transaction status to be `pending` or `published` +-- before a child spend edge can attach. +-- - Only changes rows that are currently unspent or already point at the same +-- `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +-- without allowing a caller to silently rewrite which input spent the UTXO. +-- Performance: +-- - Targets one outpoint in one wallet; the subquery uses the unique +-- wallet-scoped tx hash lookup. +UPDATE utxos +SET + spent_by_tx_id = ?4, + spent_input_index = ?5 +WHERE + utxos.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND t.tx_status IN (0, 1) + ) + AND utxos.output_index = ?3 + AND ( + (utxos.spent_by_tx_id IS NULL AND utxos.spent_input_index IS NULL) + OR (utxos.spent_by_tx_id = ?4 AND utxos.spent_input_index = ?5) + ); + +-- name: ClearUtxosSpentByTxID :execrows +-- Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +-- +-- How: +-- - Resets both spent columns together so the logical spend pointer remains +-- internally consistent. +-- Performance: +-- - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +-- ownership through the creating transaction. +UPDATE utxos +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + spent_by_tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ); + +-- name: DeleteUtxosByTxID :execrows +-- Deletes all UTXO rows created by the provided transaction ID. +-- +-- How: +-- - Removes outputs by the parent transaction's internal ID after callers have +-- already decided the transaction row itself may be deleted. +-- Performance: +-- - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +-- outputs, then rechecks wallet ownership through the parent transaction. +DELETE FROM utxos +WHERE + tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ); From 4c4fed6dcebdc3f460f278091d96ddac7685a042 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:04 +0800 Subject: [PATCH 144/695] wallet: add sqlc queries for tx_replacements --- .../db/queries/postgres/tx_replacements.sql | 130 ++++++++++++++++++ .../db/queries/sqlite/tx_replacements.sql | 129 +++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/tx_replacements.sql create mode 100644 wallet/internal/db/queries/sqlite/tx_replacements.sql diff --git a/wallet/internal/db/queries/postgres/tx_replacements.sql b/wallet/internal/db/queries/postgres/tx_replacements.sql new file mode 100644 index 0000000000..0625014fe3 --- /dev/null +++ b/wallet/internal/db/queries/postgres/tx_replacements.sql @@ -0,0 +1,130 @@ +-- name: InsertTxReplacementEdge :execrows +-- Records a replacement edge between two wallet-scoped transactions. +-- +-- How: +-- - Writes directly to tx_replacements using already-resolved transaction IDs. +-- - Relies on the wallet-scoped unique edge constraint to collapse retries into +-- one stored edge. +-- - Leaves created_at to the table default so the database records insertion +-- time for the edge. +-- Performance: +-- - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, $2, $3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: InsertTxReplacementEdgeByHash :execrows +-- Records a replacement edge by resolving tx IDs from transaction hashes. +-- +-- How: +-- - Resolves both endpoint transaction IDs from the transactions table using +-- the wallet-scoped tx-hash unique lookup. +-- - Writes the resulting directed edge to tx_replacements. +-- - Leaves created_at to the table default so the database records insertion +-- time for the edge. +-- Performance: +-- - Trades two indexed scalar subqueries for one network round trip, which is +-- preferable when callers start from tx hashes. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: ListReplacementTxIDsByReplacedTxID :many +-- Lists replacement transaction IDs for a given victim transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +-- caller already has the victim's internal row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the replacement-edge index without joining transactions. +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replaced_tx_id = $2 +ORDER BY created_at, id; + +-- name: ListReplacedTxIDsByReplacementTxID :many +-- Lists victim transaction IDs for a given replacement transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +-- the caller already has the replacement row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the inverse replacement lookup index without joining transactions. +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replacement_tx_id = $2 +ORDER BY created_at, id; + +-- name: ListReplacementTxHashesByReplacedTxHash :many +-- Lists replacement txids for a given victim txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the victim hash on the `replaced` alias. +-- Performance: +-- - The victim hash lookup narrows the graph walk before the second transaction +-- join materializes replacement hashes. +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replaced.tx_hash = $2 +ORDER BY r.created_at, r.id; + +-- name: ListReplacedTxHashesByReplacementTxHash :many +-- Lists victim txids for a given replacement txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the replacement hash on the `replacement` alias. +-- Performance: +-- - Mirrors the victim lookup path while keeping the graph traversal bounded by +-- wallet scope and indexed edge keys. +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replacement.tx_hash = $2 +ORDER BY r.created_at, r.id; diff --git a/wallet/internal/db/queries/sqlite/tx_replacements.sql b/wallet/internal/db/queries/sqlite/tx_replacements.sql new file mode 100644 index 0000000000..5b69a8b00e --- /dev/null +++ b/wallet/internal/db/queries/sqlite/tx_replacements.sql @@ -0,0 +1,129 @@ +-- name: InsertTxReplacementEdge :execrows +-- Records a replacement edge between two wallet-scoped transactions. +-- +-- How: +-- - Writes directly to tx_replacements using already-resolved transaction IDs. +-- - Uses an explicit conflict target so only duplicate edges are ignored; +-- missing transaction IDs, self-replacements, and other constraint failures +-- still surface to the caller. +-- Performance: +-- - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, ?2, ?3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: InsertTxReplacementEdgeByHash :execrows +-- Records a replacement edge by resolving tx IDs from transaction hashes. +-- +-- How: +-- - Resolves both endpoint transaction IDs from the transactions table using +-- the wallet-scoped tx-hash unique lookup. +-- - Writes the resulting directed edge to tx_replacements. +-- - Uses an explicit conflict target so duplicate-edge retries are ignored +-- without masking missing-hash or check-constraint failures. +-- Performance: +-- - Trades two indexed scalar subqueries for one network round trip, which is +-- preferable when callers start from tx hashes. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: ListReplacementTxIDsByReplacedTxID :many +-- Lists replacement transaction IDs for a given victim transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +-- caller already has the victim's internal row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the replacement-edge index without joining transactions. +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replaced_tx_id = ?2 +ORDER BY created_at, id; + +-- name: ListReplacedTxIDsByReplacementTxID :many +-- Lists victim transaction IDs for a given replacement transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +-- the caller already has the replacement row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the inverse replacement lookup index without joining transactions. +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replacement_tx_id = ?2 +ORDER BY created_at, id; + +-- name: ListReplacementTxHashesByReplacedTxHash :many +-- Lists replacement txids for a given victim txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the victim hash on the `replaced` alias. +-- Performance: +-- - The victim hash lookup narrows the graph walk before the second transaction +-- join materializes replacement hashes. +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replaced.tx_hash = ?2 +ORDER BY r.created_at, r.id; + +-- name: ListReplacedTxHashesByReplacementTxHash :many +-- Lists victim txids for a given replacement txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the replacement hash on the `replacement` alias. +-- Performance: +-- - Mirrors the victim lookup path while keeping the graph traversal bounded by +-- wallet scope and indexed edge keys. +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replacement.tx_hash = ?2 +ORDER BY r.created_at, r.id; From 91802d2acb4a3b6976455edaa41ca591ea764c73 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:13 +0800 Subject: [PATCH 145/695] wallet: add sqlc queries for utxo_leases --- .../db/queries/postgres/utxo_leases.sql | 126 ++++++++++++++++++ .../db/queries/sqlite/utxo_leases.sql | 123 +++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/utxo_leases.sql create mode 100644 wallet/internal/db/queries/sqlite/utxo_leases.sql diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/db/queries/postgres/utxo_leases.sql new file mode 100644 index 0000000000..262a2287ea --- /dev/null +++ b/wallet/internal/db/queries/postgres/utxo_leases.sql @@ -0,0 +1,126 @@ +-- name: AcquireUtxoLease :one +-- Acquires or renews a lease for an outpoint and returns the resulting +-- expiration time. +-- +-- How: +-- - Resolves the outpoint to a current UTXO row and writes the lease in the +-- same statement. +-- - Rechecks that the outpoint is still unspent and its parent transaction is +-- still in a live state (`pending` or `published`) at write time. +-- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +-- and expired-lease takeover all happen atomically. +-- Lease semantics: +-- - If the UTXO has no lease row, insert a new lease. +-- - If the UTXO has an expired lease, steal it. +-- - If the UTXO has an active lease with the same lock_id, renew it. +-- - If the UTXO has an active lease with a different lock_id, return no +-- rows (caller should treat this as "already leased"). +-- +-- NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass +-- UTC values for both expires_at and now_utc. +-- Performance: +-- - Locks the target utxo row during resolution so concurrent spend updates on +-- that row serialize with lease acquisition. +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + sqlc.arg('wallet_id') AS wallet_id, + u.id AS utxo_id, + sqlc.arg('lock_id') AS lock_id, + sqlc.arg('expires_at')::TIMESTAMP AS expires_at +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_hash = sqlc.arg('tx_hash') + AND u.output_index = sqlc.arg('output_index') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +FOR UPDATE OF u +ON CONFLICT (utxo_id) DO UPDATE + SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at + WHERE + utxo_leases.wallet_id = excluded.wallet_id + AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc')::TIMESTAMP + OR utxo_leases.lock_id = excluded.lock_id + ) +RETURNING expires_at; + +-- name: ReleaseUtxoLease :execrows +-- Releases a lease for a UTXO ID if the lock_id matches. +-- +-- How: +-- - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +-- another caller's active lease accidentally. +-- Performance: +-- - Targets at most one row through the unique lease key. +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = $1 + AND utxo_leases.utxo_id = $2 + AND utxo_leases.lock_id = $3; + +-- name: GetActiveUtxoLeaseLockID :one +-- Returns the lock ID for the current active lease on a UTXO ID. +-- +-- How: +-- - Reads only non-expired lease rows so callers can distinguish an active +-- lock-ID mismatch from an already-unlocked output. +-- Performance: +-- - Targets at most one row through the unique lease key. +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = $1 + AND utxo_id = $2 + AND expires_at > sqlc.arg('now_utc')::TIMESTAMP; + +-- name: ListActiveUtxoLeases :many +-- Lists all currently active leases for a wallet. +-- +-- How: +-- - Starts from utxo_leases, then joins utxos and transactions so the result +-- can be returned as network outpoints. +-- - Filters out expired rows using the caller-supplied UTC timestamp. +-- - Restricts the result to outputs that are still unspent and whose parent +-- transaction is still in a live state (`pending` or `published`). +-- Performance: +-- - Restricts first by wallet and expiration, then joins only the surviving +-- lease rows back to utxos/transactions. +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = $1 + AND l.expires_at > sqlc.arg('now_utc')::TIMESTAMP + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at; + +-- name: DeleteExpiredUtxoLeases :execrows +-- Deletes all expired lease rows for a wallet. +-- +-- How: +-- - Removes only rows whose expiration has passed, leaving active leases +-- untouched. +-- - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not +-- depend on database session timezone settings. +-- Performance: +-- - Uses the expiration predicate together with wallet scoping to bound the +-- cleanup pass. +DELETE FROM utxo_leases +WHERE + wallet_id = $1 + AND expires_at <= sqlc.arg('now_utc')::TIMESTAMP; diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql new file mode 100644 index 0000000000..fa6f4eb57b --- /dev/null +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -0,0 +1,123 @@ +-- name: AcquireUtxoLease :one +-- Acquires or renews a lease for an outpoint and returns the resulting +-- expiration time. +-- +-- How: +-- - Resolves the outpoint to a current UTXO row and writes the lease in the +-- same statement. +-- - Rechecks that the outpoint is still unspent and its parent transaction is +-- still in a live state (`pending` or `published`) at write time. +-- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +-- and expired-lease takeover all happen atomically. +-- Lease semantics: +-- - If the UTXO has no lease row, insert a new lease. +-- - If the UTXO has an expired lease, steal it. +-- - If the UTXO has an active lease with the same lock_id, renew it. +-- - If the UTXO has an active lease with a different lock_id, return no +-- rows (caller should treat this as "already leased"). +-- +-- NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and +-- callers must also pass a UTC-normalized now_utc for lease-expiry checks. +-- Performance: +-- - SQLite executes the resolution and lease write atomically inside one +-- statement, which avoids stale-ID races between separate helper calls. +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + sqlc.arg('wallet_id') AS wallet_id, + u.id AS utxo_id, + sqlc.arg('lock_id') AS lock_id, + sqlc.arg('expires_at') AS expires_at +FROM utxos AS u +INNER JOIN transactions AS t + ON u.tx_id = t.id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_hash = sqlc.arg('tx_hash') + AND u.output_index = sqlc.arg('output_index') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND sqlc.arg('now_utc') IS NOT NULL +ON CONFLICT (utxo_id) DO UPDATE +SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at +WHERE +utxo_leases.wallet_id = excluded.wallet_id +AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc') + OR utxo_leases.lock_id = excluded.lock_id +) +RETURNING expires_at; + +-- name: ReleaseUtxoLease :execrows +-- Releases a lease for a UTXO ID if the lock_id matches. +-- +-- How: +-- - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +-- another caller's active lease accidentally. +-- Performance: +-- - Targets at most one row through the unique lease key. +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = ?1 + AND utxo_leases.utxo_id = ?2 + AND utxo_leases.lock_id = ?3; + +-- name: GetActiveUtxoLeaseLockID :one +-- Returns the lock ID for the current active lease on a UTXO ID. +-- +-- How: +-- - Reads only non-expired lease rows so callers can distinguish an active +-- lock-ID mismatch from an already-unlocked output. +-- Performance: +-- - Targets at most one row through the unique lease key. +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = ?1 + AND utxo_id = ?2 + AND expires_at > sqlc.arg('now_utc'); + +-- name: ListActiveUtxoLeases :many +-- Lists all currently active leases for a wallet. +-- +-- How: +-- - Starts from utxo_leases, then joins utxos and transactions so the result +-- can be returned as network outpoints. +-- - Filters out expired rows using the caller-supplied UTC timestamp. +-- - Restricts the result to outputs that are still unspent and whose parent +-- transaction is still in a live state (`pending` or `published`). +-- Performance: +-- - Restricts first by wallet and expiration, then joins only the surviving +-- lease rows back to utxos/transactions. +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = ?1 + AND l.expires_at > sqlc.arg('now_utc') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at; + +-- name: DeleteExpiredUtxoLeases :execrows +-- Deletes all expired lease rows for a wallet. +-- +-- How: +-- - Removes only rows whose expiration has passed, leaving active leases +-- untouched. +-- Performance: +-- - Uses the expiration predicate together with wallet scoping to bound the +-- cleanup pass. +DELETE FROM utxo_leases +WHERE wallet_id = ?1 AND expires_at <= sqlc.arg('now_utc'); From c2222f4286e5a2619a0b39f19d80f38d715fe853 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:21 +0800 Subject: [PATCH 146/695] wallet: generate sqlc for wtxmgr --- wallet/internal/db/sqlc/postgres/db.go | 330 +++++++++ wallet/internal/db/sqlc/postgres/models.go | 37 + wallet/internal/db/sqlc/postgres/querier.go | 395 +++++++++++ .../db/sqlc/postgres/transactions.sql.go | 606 +++++++++++++++++ .../db/sqlc/postgres/tx_replacements.sql.go | 314 +++++++++ .../db/sqlc/postgres/utxo_leases.sql.go | 251 +++++++ wallet/internal/db/sqlc/postgres/utxos.sql.go | 638 +++++++++++++++++ .../internal/db/sqlc/postgres/wallets.sql.go | 6 +- wallet/internal/db/sqlc/sqlite/db.go | 330 +++++++++ wallet/internal/db/sqlc/sqlite/models.go | 37 + wallet/internal/db/sqlc/sqlite/querier.go | 391 +++++++++++ .../db/sqlc/sqlite/transactions.sql.go | 616 +++++++++++++++++ .../db/sqlc/sqlite/tx_replacements.sql.go | 313 +++++++++ .../db/sqlc/sqlite/utxo_leases.sql.go | 248 +++++++ wallet/internal/db/sqlc/sqlite/utxos.sql.go | 643 ++++++++++++++++++ 15 files changed, 5152 insertions(+), 3 deletions(-) create mode 100644 wallet/internal/db/sqlc/postgres/transactions.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/tx_replacements.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/utxo_leases.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/utxos.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/transactions.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/utxos.sql.go diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 17936fad3f..2bd999a7bc 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) + } + if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { + return nil, fmt.Errorf("error preparing query Balance: %w", err) + } + if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { + return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) + } + if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) + } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -51,12 +63,24 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteBlocksAtOrAboveHeightStmt, err = db.PrepareContext(ctx, DeleteBlocksAtOrAboveHeight); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlocksAtOrAboveHeight: %w", err) + } + if q.deleteExpiredUtxoLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredUtxoLeases: %w", err) + } if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) } if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.deleteUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, DeleteUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUnminedTransactionByHash: %w", err) + } + if q.deleteUtxosByTxIDStmt, err = db.PrepareContext(ctx, DeleteUtxosByTxID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUtxosByTxID: %w", err) + } if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) } @@ -72,6 +96,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { + return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) + } if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) } @@ -99,6 +126,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) } + if q.getTransactionByHashStmt, err = db.PrepareContext(ctx, GetTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionByHash: %w", err) + } + if q.getTransactionMetaByHashStmt, err = db.PrepareContext(ctx, GetTransactionMetaByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionMetaByHash: %w", err) + } + if q.getUtxoByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoByOutpoint: %w", err) + } + if q.getUtxoIDByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoIDByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoIDByOutpoint: %w", err) + } + if q.getUtxoSpendByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoSpendByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoSpendByOutpoint: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -117,6 +159,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) } + if q.insertTransactionStmt, err = db.PrepareContext(ctx, InsertTransaction); err != nil { + return nil, fmt.Errorf("error preparing query InsertTransaction: %w", err) + } + if q.insertTxReplacementEdgeStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdge); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdge: %w", err) + } + if q.insertTxReplacementEdgeByHashStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdgeByHash); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdgeByHash: %w", err) + } + if q.insertUtxoStmt, err = db.PrepareContext(ctx, InsertUtxo); err != nil { + return nil, fmt.Errorf("error preparing query InsertUtxo: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -135,6 +189,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) } + if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -144,18 +201,60 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } + if q.listReplacedTxHashesByReplacementTxHashStmt, err = db.PrepareContext(ctx, ListReplacedTxHashesByReplacementTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxHashesByReplacementTxHash: %w", err) + } + if q.listReplacedTxIDsByReplacementTxIDStmt, err = db.PrepareContext(ctx, ListReplacedTxIDsByReplacementTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxIDsByReplacementTxID: %w", err) + } + if q.listReplacementTxHashesByReplacedTxHashStmt, err = db.PrepareContext(ctx, ListReplacementTxHashesByReplacedTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxHashesByReplacedTxHash: %w", err) + } + if q.listReplacementTxIDsByReplacedTxIDStmt, err = db.PrepareContext(ctx, ListReplacementTxIDsByReplacedTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxIDsByReplacedTxID: %w", err) + } + if q.listRollbackCoinbaseRootsStmt, err = db.PrepareContext(ctx, ListRollbackCoinbaseRoots); err != nil { + return nil, fmt.Errorf("error preparing query ListRollbackCoinbaseRoots: %w", err) + } + if q.listSpendingTxIDsByParentTxIDStmt, err = db.PrepareContext(ctx, ListSpendingTxIDsByParentTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListSpendingTxIDsByParentTxID: %w", err) + } + if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) + } + if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) + } + if q.listUtxosStmt, err = db.PrepareContext(ctx, ListUtxos); err != nil { + return nil, fmt.Errorf("error preparing query ListUtxos: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } if q.lockAccountScopeStmt, err = db.PrepareContext(ctx, LockAccountScope); err != nil { return nil, fmt.Errorf("error preparing query LockAccountScope: %w", err) } + if q.markUtxoSpentStmt, err = db.PrepareContext(ctx, MarkUtxoSpent); err != nil { + return nil, fmt.Errorf("error preparing query MarkUtxoSpent: %w", err) + } + if q.releaseUtxoLeaseStmt, err = db.PrepareContext(ctx, ReleaseUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query ReleaseUtxoLease: %w", err) + } + if q.rewindWalletSyncStateHeightsForRollbackStmt, err = db.PrepareContext(ctx, RewindWalletSyncStateHeightsForRollback); err != nil { + return nil, fmt.Errorf("error preparing query RewindWalletSyncStateHeightsForRollback: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) + } + if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -167,6 +266,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.acquireUtxoLeaseStmt != nil { + if cerr := q.acquireUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) + } + } + if q.balanceStmt != nil { + if cerr := q.balanceStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing balanceStmt: %w", cerr) + } + } + if q.clearUtxosSpentByTxIDStmt != nil { + if cerr := q.clearUtxosSpentByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) + } + } + if q.confirmUnminedTransactionByHashStmt != nil { + if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) + } + } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -212,6 +331,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteBlocksAtOrAboveHeightStmt != nil { + if cerr := q.deleteBlocksAtOrAboveHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlocksAtOrAboveHeightStmt: %w", cerr) + } + } + if q.deleteExpiredUtxoLeasesStmt != nil { + if cerr := q.deleteExpiredUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredUtxoLeasesStmt: %w", cerr) + } + } if q.deleteKeyScopeStmt != nil { if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) @@ -222,6 +351,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.deleteUnminedTransactionByHashStmt != nil { + if cerr := q.deleteUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUnminedTransactionByHashStmt: %w", cerr) + } + } + if q.deleteUtxosByTxIDStmt != nil { + if cerr := q.deleteUtxosByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUtxosByTxIDStmt: %w", cerr) + } + } if q.getAccountByScopeAndNameStmt != nil { if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) @@ -247,6 +386,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getActiveUtxoLeaseLockIDStmt != nil { + if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) + } + } if q.getAddressByScriptPubKeyStmt != nil { if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) @@ -292,6 +436,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) } } + if q.getTransactionByHashStmt != nil { + if cerr := q.getTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionByHashStmt: %w", cerr) + } + } + if q.getTransactionMetaByHashStmt != nil { + if cerr := q.getTransactionMetaByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionMetaByHashStmt: %w", cerr) + } + } + if q.getUtxoByOutpointStmt != nil { + if cerr := q.getUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoByOutpointStmt: %w", cerr) + } + } + if q.getUtxoIDByOutpointStmt != nil { + if cerr := q.getUtxoIDByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoIDByOutpointStmt: %w", cerr) + } + } + if q.getUtxoSpendByOutpointStmt != nil { + if cerr := q.getUtxoSpendByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoSpendByOutpointStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -322,6 +491,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) } } + if q.insertTransactionStmt != nil { + if cerr := q.insertTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTransactionStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeStmt != nil { + if cerr := q.insertTxReplacementEdgeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeByHashStmt != nil { + if cerr := q.insertTxReplacementEdgeByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeByHashStmt: %w", cerr) + } + } + if q.insertUtxoStmt != nil { + if cerr := q.insertUtxoStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertUtxoStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -352,6 +541,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveUtxoLeasesStmt != nil { + if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -367,6 +561,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listReplacedTxHashesByReplacementTxHashStmt != nil { + if cerr := q.listReplacedTxHashesByReplacementTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxHashesByReplacementTxHashStmt: %w", cerr) + } + } + if q.listReplacedTxIDsByReplacementTxIDStmt != nil { + if cerr := q.listReplacedTxIDsByReplacementTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxIDsByReplacementTxIDStmt: %w", cerr) + } + } + if q.listReplacementTxHashesByReplacedTxHashStmt != nil { + if cerr := q.listReplacementTxHashesByReplacedTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxHashesByReplacedTxHashStmt: %w", cerr) + } + } + if q.listReplacementTxIDsByReplacedTxIDStmt != nil { + if cerr := q.listReplacementTxIDsByReplacedTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxIDsByReplacedTxIDStmt: %w", cerr) + } + } + if q.listRollbackCoinbaseRootsStmt != nil { + if cerr := q.listRollbackCoinbaseRootsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listRollbackCoinbaseRootsStmt: %w", cerr) + } + } + if q.listSpendingTxIDsByParentTxIDStmt != nil { + if cerr := q.listSpendingTxIDsByParentTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSpendingTxIDsByParentTxIDStmt: %w", cerr) + } + } + if q.listTransactionsByHeightRangeStmt != nil { + if cerr := q.listTransactionsByHeightRangeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) + } + } + if q.listUnminedTransactionsStmt != nil { + if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) + } + } + if q.listUtxosStmt != nil { + if cerr := q.listUtxosStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUtxosStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -377,6 +616,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing lockAccountScopeStmt: %w", cerr) } } + if q.markUtxoSpentStmt != nil { + if cerr := q.markUtxoSpentStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing markUtxoSpentStmt: %w", cerr) + } + } + if q.releaseUtxoLeaseStmt != nil { + if cerr := q.releaseUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing releaseUtxoLeaseStmt: %w", cerr) + } + } + if q.rewindWalletSyncStateHeightsForRollbackStmt != nil { + if cerr := q.rewindWalletSyncStateHeightsForRollbackStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing rewindWalletSyncStateHeightsForRollbackStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -387,6 +641,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateTransactionLabelByHashStmt != nil { + if cerr := q.updateTransactionLabelByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) + } + } + if q.updateTransactionStatusByIDsStmt != nil { + if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -436,6 +700,10 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx + acquireUtxoLeaseStmt *sql.Stmt + balanceStmt *sql.Stmt + clearUtxosSpentByTxIDStmt *sql.Stmt + confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -445,13 +713,18 @@ type Queries struct { createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + deleteBlocksAtOrAboveHeightStmt *sql.Stmt + deleteExpiredUtxoLeasesStmt *sql.Stmt deleteKeyScopeStmt *sql.Stmt deleteKeyScopeSecretsStmt *sql.Stmt + deleteUnminedTransactionByHashStmt *sql.Stmt + deleteUtxosByTxIDStmt *sql.Stmt getAccountByScopeAndNameStmt *sql.Stmt getAccountByScopeAndNumberStmt *sql.Stmt getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt @@ -461,25 +734,49 @@ type Queries struct { getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt getKeyScopeSecretsStmt *sql.Stmt + getTransactionByHashStmt *sql.Stmt + getTransactionMetaByHashStmt *sql.Stmt + getUtxoByOutpointStmt *sql.Stmt + getUtxoIDByOutpointStmt *sql.Stmt + getUtxoSpendByOutpointStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt + insertTransactionStmt *sql.Stmt + insertTxReplacementEdgeStmt *sql.Stmt + insertTxReplacementEdgeByHashStmt *sql.Stmt + insertUtxoStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt listAccountsByScopeStmt *sql.Stmt listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listReplacedTxHashesByReplacementTxHashStmt *sql.Stmt + listReplacedTxIDsByReplacementTxIDStmt *sql.Stmt + listReplacementTxHashesByReplacedTxHashStmt *sql.Stmt + listReplacementTxIDsByReplacedTxIDStmt *sql.Stmt + listRollbackCoinbaseRootsStmt *sql.Stmt + listSpendingTxIDsByParentTxIDStmt *sql.Stmt + listTransactionsByHeightRangeStmt *sql.Stmt + listUnminedTransactionsStmt *sql.Stmt + listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt lockAccountScopeStmt *sql.Stmt + markUtxoSpentStmt *sql.Stmt + releaseUtxoLeaseStmt *sql.Stmt + rewindWalletSyncStateHeightsForRollbackStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -488,6 +785,10 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, + acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + balanceStmt: q.balanceStmt, + clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, + confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -497,13 +798,18 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + deleteBlocksAtOrAboveHeightStmt: q.deleteBlocksAtOrAboveHeightStmt, + deleteExpiredUtxoLeasesStmt: q.deleteExpiredUtxoLeasesStmt, deleteKeyScopeStmt: q.deleteKeyScopeStmt, deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + deleteUnminedTransactionByHashStmt: q.deleteUnminedTransactionByHashStmt, + deleteUtxosByTxIDStmt: q.deleteUtxosByTxIDStmt, getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, @@ -513,25 +819,49 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getTransactionByHashStmt: q.getTransactionByHashStmt, + getTransactionMetaByHashStmt: q.getTransactionMetaByHashStmt, + getUtxoByOutpointStmt: q.getUtxoByOutpointStmt, + getUtxoIDByOutpointStmt: q.getUtxoIDByOutpointStmt, + getUtxoSpendByOutpointStmt: q.getUtxoSpendByOutpointStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertTransactionStmt: q.insertTransactionStmt, + insertTxReplacementEdgeStmt: q.insertTxReplacementEdgeStmt, + insertTxReplacementEdgeByHashStmt: q.insertTxReplacementEdgeByHashStmt, + insertUtxoStmt: q.insertUtxoStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, listAccountsByScopeStmt: q.listAccountsByScopeStmt, listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listReplacedTxHashesByReplacementTxHashStmt: q.listReplacedTxHashesByReplacementTxHashStmt, + listReplacedTxIDsByReplacementTxIDStmt: q.listReplacedTxIDsByReplacementTxIDStmt, + listReplacementTxHashesByReplacedTxHashStmt: q.listReplacementTxHashesByReplacedTxHashStmt, + listReplacementTxIDsByReplacedTxIDStmt: q.listReplacementTxIDsByReplacedTxIDStmt, + listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, + listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, + listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, + listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, lockAccountScopeStmt: q.lockAccountScopeStmt, + markUtxoSpentStmt: q.markUtxoSpentStmt, + releaseUtxoLeaseStmt: q.releaseUtxoLeaseStmt, + rewindWalletSyncStateHeightsForRollbackStmt: q.rewindWalletSyncStateHeightsForRollbackStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 411c05c8c6..363466e546 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -77,6 +77,43 @@ type KeyScopeSecret struct { EncryptedCoinPrivKey []byte } +type Transaction struct { + WalletID int64 + ID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt32 + TxStatus int16 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +type TxReplacement struct { + WalletID int64 + ID int64 + ReplacedTxID int64 + ReplacementTxID int64 + CreatedAt time.Time +} + +type Utxo struct { + ID int64 + TxID int64 + OutputIndex int32 + Amount int64 + AddressID int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt32 +} + +type UtxoLease struct { + WalletID int64 + UtxoID int64 + LockID []byte + ExpiresAt time.Time +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 84f628a8c8..073b78a071 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -6,9 +6,73 @@ package sqlcpg import ( "context" + "database/sql" + "time" ) type Querier interface { + // Acquires or renews a lease for an outpoint and returns the resulting + // expiration time. + // + // How: + // - Resolves the outpoint to a current UTXO row and writes the lease in the + // same statement. + // - Rechecks that the outpoint is still unspent and its parent transaction is + // still in a live state (`pending` or `published`) at write time. + // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, + // and expired-lease takeover all happen atomically. + // Lease semantics: + // - If the UTXO has no lease row, insert a new lease. + // - If the UTXO has an expired lease, steal it. + // - If the UTXO has an active lease with the same lock_id, renew it. + // - If the UTXO has an active lease with a different lock_id, return no + // rows (caller should treat this as "already leased"). + // + // NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass + // UTC values for both expires_at and now_utc. + // Performance: + // - Locks the target utxo row during resolution so concurrent spend updates on + // that row serialize with lease acquisition. + AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Returns the total and locked value represented by the wallet's current + // unspent UTXO set. + // + // How: + // - Starts from wallet-scoped unspent outputs and rejoins transactions plus + // wallet_sync_states for confirmation math. + // - Rejoins addresses -> accounts -> key_scopes so ownership validation and + // optional account filtering stay in one read. + // - Applies optional confirmation-range and coinbase-maturity policy directly + // inside the aggregate query so callers can request factual or policy-shaped + // balance reads through one public method. + // - Returns both the total matching value and the locked subset covered by + // active leases after the same filters are applied. + // Performance: + // - Executes as one aggregate over wallet-scoped live outputs. + // - Uses a filtered aggregate over active leases rather than issuing a second + // query for the locked subset. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) + // Clears spent_by pointers for all UTXOs spent by the provided transaction ID. + // + // How: + // - Resets both spent columns together so the logical spend pointer remains + // internally consistent. + // Performance: + // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet + // ownership through the creating transaction. + ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) + // Attaches a confirming block to one existing live unmined transaction row. + // + // How: + // - Updates only rows that are still blockless and live (`pending` or + // `published`). + // - Leaves user-visible metadata such as labels untouched so confirmation can + // reuse the original transaction row instead of reinserting it. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -33,10 +97,50 @@ type Querier interface { CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Deletes blocks at and after the provided height. + // + // How: + // - Deletes directly from blocks by the natural height key. + // - Relies on FK/trigger side effects to null transaction block references and + // orphan coinbase rows. + // Performance: + // - Executes as a range delete over the block-height primary key. + DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int32) (int64, error) + // Deletes all expired lease rows for a wallet. + // + // How: + // - Removes only rows whose expiration has passed, leaving active leases + // untouched. + // - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not + // depend on database session timezone settings. + // Performance: + // - Uses the expiration predicate together with wallet scoping to bound the + // cleanup pass. + DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) // Deletes a key scope by its ID. DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Deletes an unconfirmed transaction row. + // + // How: + // - Deletes only rows whose `block_height` is still NULL and whose status is + // still in a live unconfirmed state (`pending` or `published`). + // - Preserves orphaned/replaced/failed history; those rows must remain visible + // for audit/reorg handling instead of being treated as ordinary mempool data. + // - The caller must delete or restore dependent UTXO rows first. + // Performance: + // - Targets at most one row by `(wallet_id, tx_hash)`. + DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) + // Deletes all UTXO rows created by the provided transaction ID. + // + // How: + // - Removes outputs by the parent transaction's internal ID after callers have + // already decided the transaction row itself may be deleted. + // Performance: + // - Uses the `(tx_id)` index to keep the delete bounded to one transaction's + // outputs, then rechecks wallet ownership through the parent transaction. + DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) // Returns a single account by scope id and account name. GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) // Returns a single account by scope id and account number. @@ -47,6 +151,14 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns the lock ID for the current active lease on a UTXO ID. + // + // How: + // - Reads only non-expired lease rows so callers can distinguish an active + // lock-ID mismatch from an already-unlocked output. + // Performance: + // - Targets at most one row through the unique lease key. + GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) // Retrieves an address by its script pubkey and account wallet. GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -69,6 +181,66 @@ type Querier interface { GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) + // Retrieves the full transaction row along with optional block metadata. + // + // How: + // - Looks up the transaction by `(wallet_id, tx_hash)`. + // - LEFT JOINs blocks on `block_height` so the same query handles mined and + // unmined rows. + // Performance: + // - The unique transaction lookup limits the join fanout to at most one block + // row. + GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) + // Retrieves the primary key and lightweight transaction metadata. + // + // How: + // - Reads only the transactions table because callers only need row identity + // plus lightweight status/label fields. + // Performance: + // - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. + GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) + // Retrieves a single unspent UTXO by its outpoint. + // + // How: + // - Joins utxos -> transactions on `tx_id` to resolve the outpoint + // from tx hash plus output index. + // - Joins addresses -> accounts -> key_scopes so the read path reasserts that + // the credited address belongs to the requested wallet. + // - Returns leased and unleased outputs alike because leasing affects coin + // selection, not whether the UTXO exists. + // - Treats outputs from both live unconfirmed parent states (`pending` and + // `published`) as part of the wallet's current UTXO set. + // Performance: + // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the + // join fanout to at most one candidate output. + GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) + // Retrieves the database ID for a current UTXO by its outpoint. + // + // How: + // - Joins transactions on `id` so callers can address a UTXO by + // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. + // - Restricts the result to unspent outputs whose parent transaction is still + // in a live state (`pending` or `published`). + // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return + // rows whose credited address does not actually belong to the wallet. + // - Exists separately from GetUtxoByOutpoint because mutation helpers often + // need the stable internal UTXO row ID without reading the full public UTXO + // payload. + // Performance: + // - Uses the wallet-scoped transaction hash lookup first, then narrows to the + // unique `(tx_id, output_index)` outpoint. + GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) + // Returns the current spend edge for one wallet-owned outpoint. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only + // considers outputs whose parent status is `pending` or `published`. + // - Returns the nullable `spent_by_tx_id` column so callers can distinguish + // between an external/dead parent and a wallet-owned conflict. + // Performance: + // - Targets one wallet-scoped outpoint through the unique `(tx_id, + // output_index)` key after the parent hash lookup. + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) @@ -79,6 +251,52 @@ type Querier interface { // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error + // Inserts a wallet-scoped transaction row and returns its database ID. + // + // How: + // - Writes only the transactions table. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. + // - Expects the caller to supply the initial status explicitly so unmined rows + // do not have to guess between `pending` and `published`. + // Performance: + // - Single-row insert. The cost is dominated by the wallet/hash uniqueness + // checks and any optional block foreign-key validation. + InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) + // Records a replacement edge between two wallet-scoped transactions. + // + // How: + // - Writes directly to tx_replacements using already-resolved transaction IDs. + // - Relies on the wallet-scoped unique edge constraint to collapse retries into + // one stored edge. + // - Leaves created_at to the table default so the database records insertion + // time for the edge. + // Performance: + // - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. + InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) + // Records a replacement edge by resolving tx IDs from transaction hashes. + // + // How: + // - Resolves both endpoint transaction IDs from the transactions table using + // the wallet-scoped tx-hash unique lookup. + // - Writes the resulting directed edge to tx_replacements. + // - Leaves created_at to the table default so the database records insertion + // time for the edge. + // Performance: + // - Trades two indexed scalar subqueries for one network round trip, which is + // preferable when callers start from tx hashes. + InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) + // Inserts a new UTXO row and returns its database ID. + // + // How: + // - Writes only the utxos table using already-resolved transaction and address + // IDs. + // - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if + // the provided address ID belongs to the same wallet. + // Performance: + // - Single-row insert. The main cost is the wallet-ownership validation join + // plus FK and uniqueness checks. + InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -93,6 +311,18 @@ type Querier interface { // Lists all accounts for a wallet and scope tuple, ordered by account number. // Imported accounts (with NULL account_number) appear last. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists all currently active leases for a wallet. + // + // How: + // - Starts from utxo_leases, then joins utxos and transactions so the result + // can be returned as network outpoints. + // - Filters out expired rows using the caller-supplied UTC timestamp. + // - Restricts the result to outputs that are still unspent and whose parent + // transaction is still in a live state (`pending` or `published`). + // Performance: + // - Restricts first by wallet and expiration, then joins only the surviving + // lease rows back to utxos/transactions. + ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all addresses for a given account identified by wallet_id, key scope @@ -101,6 +331,115 @@ type Querier interface { ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + // Lists victim txids for a given replacement txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the replacement hash on the `replacement` alias. + // Performance: + // - Mirrors the victim lookup path while keeping the graph traversal bounded by + // wallet scope and indexed edge keys. + ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) + // Lists victim transaction IDs for a given replacement transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because + // the caller already has the replacement row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the inverse replacement lookup index without joining transactions. + ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) + // Lists replacement txids for a given victim txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the victim hash on the `replaced` alias. + // Performance: + // - The victim hash lookup narrows the graph walk before the second transaction + // join materializes replacement hashes. + ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) + // Lists replacement transaction IDs for a given victim transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the + // caller already has the victim's internal row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the replacement-edge index without joining transactions. + ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) + // Lists wallet-scoped coinbase transaction hashes at or above the rollback + // boundary that seed descendant invalidation. + // + // How: + // - Reads only confirmed coinbase rows at or above the rollback boundary. + // - Returns wallet scope alongside each tx hash so callers can treat these + // coinbase transactions as rollback roots when invalidating now-dead + // descendants inside the same rollback transaction. + // - This is a rollback-specific helper, not a generic "coinbase txs from one + // block" listing query. + // Performance: + // - Uses the block-height index to bound the scan to the rollback range. + ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int32) ([]ListRollbackCoinbaseRootsRow, error) + // Lists direct child transaction IDs for one parent transaction ID. + // + // How: + // - Reads the spend edges already materialized on utxos through + // `(tx_id, spent_by_tx_id)`. + // - Returns only direct children; callers that need full descendant walks should + // traverse this query iteratively in application code. + // Performance: + // - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded + // to one wallet-scoped parent. + ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) + // Lists all confirmed transactions for a wallet in the provided height range. + // + // How: + // - Reads transactions in a wallet-scoped block-height range. + // - INNER JOINs blocks on the natural `block_height` key to hydrate block hash + // and timestamp for confirmed rows. + // Performance: + // - The `(wallet_id, block_height)` index bounds the scan before the single-row + // block join. + ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists all unconfirmed transactions for a wallet. + // + // How: + // - Reads from transactions only and filters on blockless rows that are still + // in a live unconfirmed state (`pending` or `published`). + // - Excludes orphaned/replaced/failed history so rollback-produced coinbase + // rows do not reappear as mempool transactions. + // - Returns typed NULL block metadata explicitly because live unmined rows have + // no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the unmined row shape aligned with the confirmed query below. + // Performance: + // - Matches the dedicated blockless-history index while the more selective + // live-only partial index stays available for conflict paths. + ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) + // Lists unspent UTXOs that match the provided filters. + // + // How: + // - Starts from utxos and joins transactions for tx metadata plus + // wallet_sync_states for confirmation math. + // - Joins addresses to return the required script_pub_key, then reuses + // addresses -> accounts -> key_scopes so account filtering and wallet + // ownership checks happen in the same read. + // - Returns leased outputs too because the API models leases separately from + // UTXO existence. + // - Includes outputs whose parent transaction is still in a live unconfirmed + // state (`pending` or `published`). + // - Intentionally does not enforce coinbase maturity because this query models + // wallet-owned UTXO existence rather than a strictly spendable subset. + // Performance: + // - Restricts first by wallet, spend state, and transaction status. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + // - Treats min/max confirmations as optional filters so callers can + // distinguish "not set" from an explicit zero-conf request. + ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) // Acquires a transaction-level advisory lock to serialize account creation within a scope. // The lock is automatically released upon transaction commit or rollback. @@ -126,10 +465,66 @@ type Querier interface { // LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() // operates inside a strictly serialized execution window for that scope. LockAccountScope(ctx context.Context, dollar_1 int64) error + // Marks a wallet-owned UTXO as spent by a transaction. + // + // How: + // - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside + // the statement so callers can update by network outpoint. + // - Requires the parent transaction status to be `pending` or `published` + // before a child spend edge can attach. + // - Only changes rows that are currently unspent or already point at the same + // `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent + // without allowing a caller to silently rewrite which input spent the UTXO. + // Performance: + // - Targets one outpoint in one wallet; the subquery uses the unique + // wallet-scoped tx hash lookup. + MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) + // Releases a lease for a UTXO ID if the lock_id matches. + // + // How: + // - Deletes by wallet, utxo ID, and lock ID so one caller cannot release + // another caller's active lease accidentally. + // Performance: + // - Targets at most one row through the unique lease key. + ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) + // Rewrites wallet sync-state heights so they stop referencing blocks that are + // about to be deleted during RollbackToBlock. + // + // How: + // - Updates wallet_sync_states directly without joining other tables. + // - Rewrites both synced_height and birthday_height in one statement so the + // subsequent block delete does not violate `ON DELETE RESTRICT`. + // - Example: if `rollback_height = 195`, then any `synced_height` or + // `birthday_height` at 195 or above rewinds to `new_height = 194`. + // - If rollback starts from height 0, callers pass `new_height = NULL` so the + // sync state no longer points at any surviving block row. + // Performance: + // - Touches only wallet_sync_states rows whose heights are at or above the + // rollback boundary. + RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates only the user-visible transaction label. + // + // How: + // - Leaves block assignment and status untouched. + // - Exists for user-facing metadata edits only; wallet-internal state + // transitions use dedicated helper queries. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the wallet-relative status for a set of transaction row IDs. + // + // How: + // - Exists for wallet-internal replacement and invalidation flows after the + // caller has already identified the affected rows. + // - Leaves block assignment untouched; rollback/disconnect continues to use the + // dedicated rewind helpers below. + // Performance: + // - Restricts by wallet scope first, then matches only the provided ID set. + UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go new file mode 100644 index 0000000000..2cb9a124e7 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -0,0 +1,606 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: transactions.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" +) + +const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows +UPDATE transactions +SET + block_height = $1::INTEGER, + tx_status = 1 +WHERE + wallet_id = $2 + AND tx_hash = $3 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type ConfirmUnminedTransactionByHashParams struct { + BlockHeight int32 + WalletID int64 + TxHash []byte +} + +// Attaches a confirming block to one existing live unmined transaction row. +// +// How: +// - Updates only rows that are still blockless and live (`pending` or +// `published`). +// - Leaves user-visible metadata such as labels untouched so confirmation can +// reuse the original transaction row instead of reinserting it. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows +DELETE FROM blocks +WHERE block_height >= $1 +` + +// Deletes blocks at and after the provided height. +// +// How: +// - Deletes directly from blocks by the natural height key. +// - Relies on FK/trigger side effects to null transaction block references and +// orphan coinbase rows. +// +// Performance: +// - Executes as a range delete over the block-height primary key. +func (q *Queries) DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int32) (int64, error) { + result, err := q.exec(ctx, q.deleteBlocksAtOrAboveHeightStmt, DeleteBlocksAtOrAboveHeight, blockHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUnminedTransactionByHash = `-- name: DeleteUnminedTransactionByHash :execrows +DELETE FROM transactions +WHERE + wallet_id = $1 + AND tx_hash = $2 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type DeleteUnminedTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +// Deletes an unconfirmed transaction row. +// +// How: +// - Deletes only rows whose `block_height` is still NULL and whose status is +// still in a live unconfirmed state (`pending` or `published`). +// - Preserves orphaned/replaced/failed history; those rows must remain visible +// for audit/reorg handling instead of being treated as ordinary mempool data. +// - The caller must delete or restore dependent UTXO rows first. +// +// Performance: +// - Targets at most one row by `(wallet_id, tx_hash)`. +func (q *Queries) DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUnminedTransactionByHashStmt, DeleteUnminedTransactionByHash, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetTransactionByHash = `-- name: GetTransactionByHash :one +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = $1 AND t.tx_hash = $2 +` + +type GetTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionByHashRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Retrieves the full transaction row along with optional block metadata. +// +// How: +// - Looks up the transaction by `(wallet_id, tx_hash)`. +// - LEFT JOINs blocks on `block_height` so the same query handles mined and +// unmined rows. +// +// Performance: +// - The unique transaction lookup limits the join fanout to at most one block +// row. +func (q *Queries) GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionByHashStmt, GetTransactionByHash, arg.WalletID, arg.TxHash) + var i GetTransactionByHashRow + err := row.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const GetTransactionMetaByHash = `-- name: GetTransactionMetaByHash :one +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = $1 AND tx_hash = $2 +` + +type GetTransactionMetaByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionMetaByHashRow struct { + ID int64 + BlockHeight sql.NullInt32 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Retrieves the primary key and lightweight transaction metadata. +// +// How: +// - Reads only the transactions table because callers only need row identity +// plus lightweight status/label fields. +// +// Performance: +// - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +func (q *Queries) GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionMetaByHashStmt, GetTransactionMetaByHash, arg.WalletID, arg.TxHash) + var i GetTransactionMetaByHashRow + err := row.Scan( + &i.ID, + &i.BlockHeight, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const InsertTransaction = `-- name: InsertTransaction :one +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING id +` + +type InsertTransactionParams struct { + WalletID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt32 + TxStatus int16 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +// Inserts a wallet-scoped transaction row and returns its database ID. +// +// How: +// - Writes only the transactions table. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. +// - Expects the caller to supply the initial status explicitly so unmined rows +// do not have to guess between `pending` and `published`. +// +// Performance: +// - Single-row insert. The cost is dominated by the wallet/hash uniqueness +// checks and any optional block foreign-key validation. +func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { + row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, + arg.WalletID, + arg.TxHash, + arg.RawTx, + arg.BlockHeight, + arg.TxStatus, + arg.ReceivedTime, + arg.IsCoinbase, + arg.TxLabel, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListRollbackCoinbaseRoots = `-- name: ListRollbackCoinbaseRoots :many +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= $1::INTEGER + AND is_coinbase +ORDER BY wallet_id, id +` + +type ListRollbackCoinbaseRootsRow struct { + WalletID int64 + TxHash []byte +} + +// Lists wallet-scoped coinbase transaction hashes at or above the rollback +// boundary that seed descendant invalidation. +// +// How: +// - Reads only confirmed coinbase rows at or above the rollback boundary. +// - Returns wallet scope alongside each tx hash so callers can treat these +// coinbase transactions as rollback roots when invalidating now-dead +// descendants inside the same rollback transaction. +// - This is a rollback-specific helper, not a generic "coinbase txs from one +// block" listing query. +// +// Performance: +// - Uses the block-height index to bound the scan to the rollback range. +func (q *Queries) ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int32) ([]ListRollbackCoinbaseRootsRow, error) { + rows, err := q.query(ctx, q.listRollbackCoinbaseRootsStmt, ListRollbackCoinbaseRoots, rollbackHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRollbackCoinbaseRootsRow + for rows.Next() { + var i ListRollbackCoinbaseRootsRow + if err := rows.Scan(&i.WalletID, &i.TxHash); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListTransactionsByHeightRange = `-- name: ListTransactionsByHeightRange :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = $1 + AND t.block_height BETWEEN + $2::INTEGER + AND $3::INTEGER +ORDER BY t.block_height, t.id +` + +type ListTransactionsByHeightRangeParams struct { + WalletID int64 + StartHeight int32 + EndHeight int32 +} + +type ListTransactionsByHeightRangeRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp int64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists all confirmed transactions for a wallet in the provided height range. +// +// How: +// - Reads transactions in a wallet-scoped block-height range. +// - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +// and timestamp for confirmed rows. +// +// Performance: +// - The `(wallet_id, block_height)` index bounds the scan before the single-row +// block join. +func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) { + rows, err := q.query(ctx, q.listTransactionsByHeightRangeStmt, ListTransactionsByHeightRange, arg.WalletID, arg.StartHeight, arg.EndHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsByHeightRangeRow + for rows.Next() { + var i ListTransactionsByHeightRangeRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUnminedTransactions = `-- name: ListUnminedTransactions :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC +` + +type ListUnminedTransactionsRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists all unconfirmed transactions for a wallet. +// +// How: +// - Reads from transactions only and filters on blockless rows that are still +// in a live unconfirmed state (`pending` or `published`). +// - Excludes orphaned/replaced/failed history so rollback-produced coinbase +// rows do not reappear as mempool transactions. +// - Returns typed NULL block metadata explicitly because live unmined rows have +// no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the unmined row shape aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated blockless-history index while the more selective +// live-only partial index stays available for conflict paths. +func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { + rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedTransactionsRow + for rows.Next() { + var i ListUnminedTransactionsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RewindWalletSyncStateHeightsForRollback = `-- name: RewindWalletSyncStateHeightsForRollback :execrows +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= $1::INTEGER + THEN $2 + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= $1::INTEGER + THEN $2 + ELSE birthday_height + END, + updated_at = current_timestamp AT TIME ZONE 'UTC' +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= $1::INTEGER + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= $1::INTEGER + ) +` + +type RewindWalletSyncStateHeightsForRollbackParams struct { + RollbackHeight int32 + NewHeight sql.NullInt32 +} + +// Rewrites wallet sync-state heights so they stop referencing blocks that are +// about to be deleted during RollbackToBlock. +// +// How: +// - Updates wallet_sync_states directly without joining other tables. +// - Rewrites both synced_height and birthday_height in one statement so the +// subsequent block delete does not violate `ON DELETE RESTRICT`. +// - Example: if `rollback_height = 195`, then any `synced_height` or +// `birthday_height` at 195 or above rewinds to `new_height = 194`. +// - If rollback starts from height 0, callers pass `new_height = NULL` so the +// sync state no longer points at any surviving block row. +// +// Performance: +// - Touches only wallet_sync_states rows whose heights are at or above the +// rollback boundary. +func (q *Queries) RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) { + result, err := q.exec(ctx, q.rewindWalletSyncStateHeightsForRollbackStmt, RewindWalletSyncStateHeightsForRollback, arg.RollbackHeight, arg.NewHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionLabelByHash = `-- name: UpdateTransactionLabelByHash :execrows +UPDATE transactions +SET tx_label = $1 +WHERE + wallet_id = $2 + AND tx_hash = $3 +` + +type UpdateTransactionLabelByHashParams struct { + Label string + WalletID int64 + TxHash []byte +} + +// Updates only the user-visible transaction label. +// +// How: +// - Leaves block assignment and status untouched. +// - Exists for user-facing metadata edits only; wallet-internal state +// transitions use dedicated helper queries. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionLabelByHashStmt, UpdateTransactionLabelByHash, arg.Label, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows +UPDATE transactions +SET tx_status = $1 +WHERE + wallet_id = $2 + AND id = any($3::BIGINT []) +` + +type UpdateTransactionStatusByIDsParams struct { + Status int16 + WalletID int64 + TxIds []int64 +} + +// Updates the wallet-relative status for a set of transaction row IDs. +// +// How: +// - Exists for wallet-internal replacement and invalidation flows after the +// caller has already identified the affected rows. +// - Leaves block assignment untouched; rollback/disconnect continues to use the +// dedicated rewind helpers below. +// +// Performance: +// - Restricts by wallet scope first, then matches only the provided ID set. +func (q *Queries) UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStatusByIDsStmt, UpdateTransactionStatusByIDs, arg.Status, arg.WalletID, pq.Array(arg.TxIds)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go b/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go new file mode 100644 index 0000000000..c536d3710b --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go @@ -0,0 +1,314 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: tx_replacements.sql + +package sqlcpg + +import ( + "context" + "time" +) + +const InsertTxReplacementEdge = `-- name: InsertTxReplacementEdge :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, $2, $3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeParams struct { + WalletID int64 + ReplacedTxID int64 + ReplacementTxID int64 +} + +// Records a replacement edge between two wallet-scoped transactions. +// +// How: +// - Writes directly to tx_replacements using already-resolved transaction IDs. +// - Relies on the wallet-scoped unique edge constraint to collapse retries into +// one stored edge. +// - Leaves created_at to the table default so the database records insertion +// time for the edge. +// +// Performance: +// - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +func (q *Queries) InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeStmt, InsertTxReplacementEdge, arg.WalletID, arg.ReplacedTxID, arg.ReplacementTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const InsertTxReplacementEdgeByHash = `-- name: InsertTxReplacementEdgeByHash :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeByHashParams struct { + WalletID int64 + TxHash []byte + TxHash_2 []byte +} + +// Records a replacement edge by resolving tx IDs from transaction hashes. +// +// How: +// - Resolves both endpoint transaction IDs from the transactions table using +// the wallet-scoped tx-hash unique lookup. +// - Writes the resulting directed edge to tx_replacements. +// - Leaves created_at to the table default so the database records insertion +// time for the edge. +// +// Performance: +// - Trades two indexed scalar subqueries for one network round trip, which is +// preferable when callers start from tx hashes. +func (q *Queries) InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeByHashStmt, InsertTxReplacementEdgeByHash, arg.WalletID, arg.TxHash, arg.TxHash_2) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const ListReplacedTxHashesByReplacementTxHash = `-- name: ListReplacedTxHashesByReplacementTxHash :many +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replacement.tx_hash = $2 +ORDER BY r.created_at, r.id +` + +type ListReplacedTxHashesByReplacementTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacedTxHashesByReplacementTxHashRow struct { + ReplacedTxHash []byte + CreatedAt time.Time +} + +// Lists victim txids for a given replacement txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the replacement hash on the `replacement` alias. +// +// Performance: +// - Mirrors the victim lookup path while keeping the graph traversal bounded by +// wallet scope and indexed edge keys. +func (q *Queries) ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacedTxHashesByReplacementTxHashStmt, ListReplacedTxHashesByReplacementTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxHashesByReplacementTxHashRow + for rows.Next() { + var i ListReplacedTxHashesByReplacementTxHashRow + if err := rows.Scan(&i.ReplacedTxHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacedTxIDsByReplacementTxID = `-- name: ListReplacedTxIDsByReplacementTxID :many +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replacement_tx_id = $2 +ORDER BY created_at, id +` + +type ListReplacedTxIDsByReplacementTxIDParams struct { + WalletID int64 + ReplacementTxID int64 +} + +type ListReplacedTxIDsByReplacementTxIDRow struct { + ReplacedTxID int64 + CreatedAt time.Time +} + +// Lists victim transaction IDs for a given replacement transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +// the caller already has the replacement row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the inverse replacement lookup index without joining transactions. +func (q *Queries) ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacedTxIDsByReplacementTxIDStmt, ListReplacedTxIDsByReplacementTxID, arg.WalletID, arg.ReplacementTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxIDsByReplacementTxIDRow + for rows.Next() { + var i ListReplacedTxIDsByReplacementTxIDRow + if err := rows.Scan(&i.ReplacedTxID, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacementTxHashesByReplacedTxHash = `-- name: ListReplacementTxHashesByReplacedTxHash :many +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replaced.tx_hash = $2 +ORDER BY r.created_at, r.id +` + +type ListReplacementTxHashesByReplacedTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacementTxHashesByReplacedTxHashRow struct { + ReplacementTxHash []byte + CreatedAt time.Time +} + +// Lists replacement txids for a given victim txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the victim hash on the `replaced` alias. +// +// Performance: +// - The victim hash lookup narrows the graph walk before the second transaction +// join materializes replacement hashes. +func (q *Queries) ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacementTxHashesByReplacedTxHashStmt, ListReplacementTxHashesByReplacedTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxHashesByReplacedTxHashRow + for rows.Next() { + var i ListReplacementTxHashesByReplacedTxHashRow + if err := rows.Scan(&i.ReplacementTxHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacementTxIDsByReplacedTxID = `-- name: ListReplacementTxIDsByReplacedTxID :many +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replaced_tx_id = $2 +ORDER BY created_at, id +` + +type ListReplacementTxIDsByReplacedTxIDParams struct { + WalletID int64 + ReplacedTxID int64 +} + +type ListReplacementTxIDsByReplacedTxIDRow struct { + ReplacementTxID int64 + CreatedAt time.Time +} + +// Lists replacement transaction IDs for a given victim transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +// caller already has the victim's internal row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the replacement-edge index without joining transactions. +func (q *Queries) ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacementTxIDsByReplacedTxIDStmt, ListReplacementTxIDsByReplacedTxID, arg.WalletID, arg.ReplacedTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxIDsByReplacedTxIDRow + for rows.Next() { + var i ListReplacementTxIDsByReplacedTxIDRow + if err := rows.Scan(&i.ReplacementTxID, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go new file mode 100644 index 0000000000..354d7b34ba --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go @@ -0,0 +1,251 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxo_leases.sql + +package sqlcpg + +import ( + "context" + "time" +) + +const AcquireUtxoLease = `-- name: AcquireUtxoLease :one +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + $1 AS wallet_id, + u.id AS utxo_id, + $2 AS lock_id, + $3::TIMESTAMP AS expires_at +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $4 + AND u.output_index = $5 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +FOR UPDATE OF u +ON CONFLICT (utxo_id) DO UPDATE + SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at + WHERE + utxo_leases.wallet_id = excluded.wallet_id + AND ( + utxo_leases.expires_at <= $6::TIMESTAMP + OR utxo_leases.lock_id = excluded.lock_id + ) +RETURNING expires_at +` + +type AcquireUtxoLeaseParams struct { + WalletID int64 + LockID []byte + ExpiresAt time.Time + TxHash []byte + OutputIndex int32 + NowUtc time.Time +} + +// Acquires or renews a lease for an outpoint and returns the resulting +// expiration time. +// +// How: +// - Resolves the outpoint to a current UTXO row and writes the lease in the +// same statement. +// - Rechecks that the outpoint is still unspent and its parent transaction is +// still in a live state (`pending` or `published`) at write time. +// - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +// and expired-lease takeover all happen atomically. +// +// Lease semantics: +// - If the UTXO has no lease row, insert a new lease. +// - If the UTXO has an expired lease, steal it. +// - If the UTXO has an active lease with the same lock_id, renew it. +// - If the UTXO has an active lease with a different lock_id, return no +// rows (caller should treat this as "already leased"). +// +// NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass +// UTC values for both expires_at and now_utc. +// Performance: +// - Locks the target utxo row during resolution so concurrent spend updates on +// that row serialize with lease acquisition. +func (q *Queries) AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) { + row := q.queryRow(ctx, q.acquireUtxoLeaseStmt, AcquireUtxoLease, + arg.WalletID, + arg.LockID, + arg.ExpiresAt, + arg.TxHash, + arg.OutputIndex, + arg.NowUtc, + ) + var expires_at time.Time + err := row.Scan(&expires_at) + return expires_at, err +} + +const DeleteExpiredUtxoLeases = `-- name: DeleteExpiredUtxoLeases :execrows +DELETE FROM utxo_leases +WHERE + wallet_id = $1 + AND expires_at <= $2::TIMESTAMP +` + +type DeleteExpiredUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +// Deletes all expired lease rows for a wallet. +// +// How: +// - Removes only rows whose expiration has passed, leaving active leases +// untouched. +// - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not +// depend on database session timezone settings. +// +// Performance: +// - Uses the expiration predicate together with wallet scoping to bound the +// cleanup pass. +func (q *Queries) DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) { + result, err := q.exec(ctx, q.deleteExpiredUtxoLeasesStmt, DeleteExpiredUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetActiveUtxoLeaseLockID = `-- name: GetActiveUtxoLeaseLockID :one +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = $1 + AND utxo_id = $2 + AND expires_at > $3::TIMESTAMP +` + +type GetActiveUtxoLeaseLockIDParams struct { + WalletID int64 + UtxoID int64 + NowUtc time.Time +} + +// Returns the lock ID for the current active lease on a UTXO ID. +// +// How: +// - Reads only non-expired lease rows so callers can distinguish an active +// lock-ID mismatch from an already-unlocked output. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) { + row := q.queryRow(ctx, q.getActiveUtxoLeaseLockIDStmt, GetActiveUtxoLeaseLockID, arg.WalletID, arg.UtxoID, arg.NowUtc) + var lock_id []byte + err := row.Scan(&lock_id) + return lock_id, err +} + +const ListActiveUtxoLeases = `-- name: ListActiveUtxoLeases :many +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = $1 + AND l.expires_at > $2::TIMESTAMP + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at +` + +type ListActiveUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +type ListActiveUtxoLeasesRow struct { + TxHash []byte + OutputIndex int32 + LockID []byte + ExpiresAt time.Time +} + +// Lists all currently active leases for a wallet. +// +// How: +// - Starts from utxo_leases, then joins utxos and transactions so the result +// can be returned as network outpoints. +// - Filters out expired rows using the caller-supplied UTC timestamp. +// - Restricts the result to outputs that are still unspent and whose parent +// transaction is still in a live state (`pending` or `published`). +// +// Performance: +// - Restricts first by wallet and expiration, then joins only the surviving +// lease rows back to utxos/transactions. +func (q *Queries) ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) { + rows, err := q.query(ctx, q.listActiveUtxoLeasesStmt, ListActiveUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUtxoLeasesRow + for rows.Next() { + var i ListActiveUtxoLeasesRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.LockID, + &i.ExpiresAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ReleaseUtxoLease = `-- name: ReleaseUtxoLease :execrows +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = $1 + AND utxo_leases.utxo_id = $2 + AND utxo_leases.lock_id = $3 +` + +type ReleaseUtxoLeaseParams struct { + WalletID int64 + UtxoID int64 + LockID []byte +} + +// Releases a lease for a UTXO ID if the lock_id matches. +// +// How: +// - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +// another caller's active lease accidentally. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) { + result, err := q.exec(ctx, q.releaseUtxoLeaseStmt, ReleaseUtxoLease, arg.WalletID, arg.UtxoID, arg.LockID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go new file mode 100644 index 0000000000..2363718f03 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -0,0 +1,638 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxos.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const Balance = `-- name: Balance :one +SELECT + (coalesce(sum(u.amount), 0))::BIGINT AS total_balance, + (coalesce( + sum(u.amount) FILTER ( + WHERE EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > $1::TIMESTAMP + ) + ), + 0 + ))::BIGINT AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = $2 + AND ks.wallet_id = $2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + $3::BIGINT IS NULL + OR acc.account_number = $3::BIGINT + ) + AND ( + $4::INTEGER IS NULL + OR $4::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $4::INTEGER + ) + AND ( + $5::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= $5::INTEGER + ) + AND ( + $6::INTEGER IS NULL + OR $6::INTEGER = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $6::INTEGER + ) +` + +type BalanceParams struct { + NowUtc time.Time + WalletID int64 + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt32 + MaxConfirms sql.NullInt32 + CoinbaseMaturity sql.NullInt32 +} + +type BalanceRow struct { + TotalBalance int64 + LockedBalance int64 +} + +// Returns the total and locked value represented by the wallet's current +// unspent UTXO set. +// +// How: +// - Starts from wallet-scoped unspent outputs and rejoins transactions plus +// wallet_sync_states for confirmation math. +// - Rejoins addresses -> accounts -> key_scopes so ownership validation and +// optional account filtering stay in one read. +// - Applies optional confirmation-range and coinbase-maturity policy directly +// inside the aggregate query so callers can request factual or policy-shaped +// balance reads through one public method. +// - Returns both the total matching value and the locked subset covered by +// active leases after the same filters are applied. +// +// Performance: +// - Executes as one aggregate over wallet-scoped live outputs. +// - Uses a filtered aggregate over active leases rather than issuing a second +// query for the locked subset. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +func (q *Queries) Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) { + row := q.queryRow(ctx, q.balanceStmt, Balance, + arg.NowUtc, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + arg.CoinbaseMaturity, + ) + var i BalanceRow + err := row.Scan(&i.TotalBalance, &i.LockedBalance) + return i, err +} + +const ClearUtxosSpentByTxID = `-- name: ClearUtxosSpentByTxID :execrows +UPDATE utxos AS u +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + u.spent_by_tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +` + +type ClearUtxosSpentByTxIDParams struct { + WalletID int64 + SpentByTxID sql.NullInt64 +} + +// Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +// +// How: +// - Resets both spent columns together so the logical spend pointer remains +// internally consistent. +// +// Performance: +// - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +// ownership through the creating transaction. +func (q *Queries) ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.clearUtxosSpentByTxIDStmt, ClearUtxosSpentByTxID, arg.WalletID, arg.SpentByTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUtxosByTxID = `-- name: DeleteUtxosByTxID :execrows +DELETE FROM utxos AS u +WHERE + u.tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +` + +type DeleteUtxosByTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Deletes all UTXO rows created by the provided transaction ID. +// +// How: +// - Removes outputs by the parent transaction's internal ID after callers have +// already decided the transaction row itself may be deleted. +// +// Performance: +// - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +// outputs, then rechecks wallet ownership through the parent transaction. +func (q *Queries) DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUtxosByTxIDStmt, DeleteUtxosByTxID, arg.WalletID, arg.TxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetUtxoByOutpoint = `-- name: GetUtxoByOutpoint :one +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +type GetUtxoByOutpointRow struct { + TxHash []byte + OutputIndex int32 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt32 +} + +// Retrieves a single unspent UTXO by its outpoint. +// +// How: +// - Joins utxos -> transactions on `tx_id` to resolve the outpoint +// from tx hash plus output index. +// - Joins addresses -> accounts -> key_scopes so the read path reasserts that +// the credited address belongs to the requested wallet. +// - Returns leased and unleased outputs alike because leasing affects coin +// selection, not whether the UTXO exists. +// - Treats outputs from both live unconfirmed parent states (`pending` and +// `published`) as part of the wallet's current UTXO set. +// +// Performance: +// - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +// join fanout to at most one candidate output. +func (q *Queries) GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) { + row := q.queryRow(ctx, q.getUtxoByOutpointStmt, GetUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var i GetUtxoByOutpointRow + err := row.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ) + return i, err +} + +const GetUtxoIDByOutpoint = `-- name: GetUtxoIDByOutpoint :one +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoIDByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Retrieves the database ID for a current UTXO by its outpoint. +// +// How: +// - Joins transactions on `id` so callers can address a UTXO by +// network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +// - Restricts the result to unspent outputs whose parent transaction is still +// in a live state (`pending` or `published`). +// - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +// rows whose credited address does not actually belong to the wallet. +// - Exists separately from GetUtxoByOutpoint because mutation helpers often +// need the stable internal UTXO row ID without reading the full public UTXO +// payload. +// +// Performance: +// - Uses the wallet-scoped transaction hash lookup first, then narrows to the +// unique `(tx_id, output_index)` outpoint. +func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) { + row := q.queryRow(ctx, q.getUtxoIDByOutpointStmt, GetUtxoIDByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one +SELECT u.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status IN (0, 1) +` + +type GetUtxoSpendByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Returns the current spend edge for one wallet-owned outpoint. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +// considers outputs whose parent status is `pending` or `published`. +// - Returns the nullable `spent_by_tx_id` column so callers can distinguish +// between an external/dead parent and a wallet-owned conflict. +// +// Performance: +// - Targets one wallet-scoped outpoint through the unique `(tx_id, +// output_index)` key after the parent hash lookup. +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) { + row := q.queryRow(ctx, q.getUtxoSpendByOutpointStmt, GetUtxoSpendByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var spent_by_tx_id sql.NullInt64 + err := row.Scan(&spent_by_tx_id) + return spent_by_tx_id, err +} + +const InsertUtxo = `-- name: InsertUtxo :one +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + $1 AS output_index, + $2 AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = $3 + AND t.wallet_id = $4 + AND t.tx_status IN (0, 1) + AND + a.id = $5 + AND ks.wallet_id = $4 +RETURNING id +` + +type InsertUtxoParams struct { + OutputIndex int32 + Amount int64 + TxID int64 + WalletID int64 + AddressID int64 +} + +// Inserts a new UTXO row and returns its database ID. +// +// How: +// - Writes only the utxos table using already-resolved transaction and address +// IDs. +// - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +// the provided address ID belongs to the same wallet. +// +// Performance: +// - Single-row insert. The main cost is the wallet-ownership validation join +// plus FK and uniqueness checks. +func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) { + row := q.queryRow(ctx, q.insertUtxoStmt, InsertUtxo, + arg.OutputIndex, + arg.Amount, + arg.TxID, + arg.WalletID, + arg.AddressID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = $2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +ORDER BY u.spent_by_tx_id +` + +type ListSpendingTxIDsByParentTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Lists direct child transaction IDs for one parent transaction ID. +// +// How: +// - Reads the spend edges already materialized on utxos through +// `(tx_id, spent_by_tx_id)`. +// - Returns only direct children; callers that need full descendant walks should +// traverse this query iteratively in application code. +// +// Performance: +// - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +// to one wallet-scoped parent. +func (q *Queries) ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) { + rows, err := q.query(ctx, q.listSpendingTxIDsByParentTxIDStmt, ListSpendingTxIDsByParentTxID, arg.WalletID, arg.TxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullInt64 + for rows.Next() { + var spent_by_tx_id sql.NullInt64 + if err := rows.Scan(&spent_by_tx_id); err != nil { + return nil, err + } + items = append(items, spent_by_tx_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUtxos = `-- name: ListUtxos :many +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + $2::BIGINT IS NULL + OR acc.account_number = $2::BIGINT + ) + AND ( + $3::INTEGER IS NULL + OR $3::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $3::INTEGER + ) + AND ( + $4::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= $4::INTEGER + ) +ORDER BY u.amount, t.tx_hash, u.output_index +` + +type ListUtxosParams struct { + WalletID int64 + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt32 + MaxConfirms sql.NullInt32 +} + +type ListUtxosRow struct { + TxHash []byte + OutputIndex int32 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt32 +} + +// Lists unspent UTXOs that match the provided filters. +// +// How: +// - Starts from utxos and joins transactions for tx metadata plus +// wallet_sync_states for confirmation math. +// - Joins addresses to return the required script_pub_key, then reuses +// addresses -> accounts -> key_scopes so account filtering and wallet +// ownership checks happen in the same read. +// - Returns leased outputs too because the API models leases separately from +// UTXO existence. +// - Includes outputs whose parent transaction is still in a live unconfirmed +// state (`pending` or `published`). +// - Intentionally does not enforce coinbase maturity because this query models +// wallet-owned UTXO existence rather than a strictly spendable subset. +// +// Performance: +// - Restricts first by wallet, spend state, and transaction status. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +// - Treats min/max confirmations as optional filters so callers can +// distinguish "not set" from an explicit zero-conf request. +func (q *Queries) ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) { + rows, err := q.query(ctx, q.listUtxosStmt, ListUtxos, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUtxosRow + for rows.Next() { + var i ListUtxosRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const MarkUtxoSpent = `-- name: MarkUtxoSpent :execrows +UPDATE utxos AS u +SET + spent_by_tx_id = $4, + spent_input_index = $5 +WHERE + u.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND t.tx_status IN (0, 1) + ) + AND u.output_index = $3 + AND ( + (u.spent_by_tx_id IS NULL AND u.spent_input_index IS NULL) + OR (u.spent_by_tx_id = $4 AND u.spent_input_index = $5) + ) +` + +type MarkUtxoSpentParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt32 +} + +// Marks a wallet-owned UTXO as spent by a transaction. +// +// How: +// - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +// the statement so callers can update by network outpoint. +// - Requires the parent transaction status to be `pending` or `published` +// before a child spend edge can attach. +// - Only changes rows that are currently unspent or already point at the same +// `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +// without allowing a caller to silently rewrite which input spent the UTXO. +// +// Performance: +// - Targets one outpoint in one wallet; the subquery uses the unique +// wallet-scoped tx hash lookup. +func (q *Queries) MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) { + result, err := q.exec(ctx, q.markUtxoSpentStmt, MarkUtxoSpent, + arg.WalletID, + arg.TxHash, + arg.OutputIndex, + arg.SpentByTxID, + arg.SpentInputIndex, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index cddc263bf3..0aa4daccb2 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday_timestamp, updated_at ) VALUES ( - $1, $2, $3, $4, current_timestamp + $1, $2, $3, $4, current_timestamp AT TIME ZONE 'UTC' ) ` @@ -371,8 +371,8 @@ SET -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. birthday_timestamp = coalesce($4, birthday_timestamp), - -- Always update timestamp to current database time. - updated_at = current_timestamp + -- Always update timestamp to current database time in UTC. + updated_at = current_timestamp AT TIME ZONE 'UTC' WHERE wallet_id = $1 ` diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index b53304ffad..62018917a4 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) + } + if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { + return nil, fmt.Errorf("error preparing query Balance: %w", err) + } + if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { + return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) + } + if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) + } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -51,12 +63,24 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteBlocksAtOrAboveHeightStmt, err = db.PrepareContext(ctx, DeleteBlocksAtOrAboveHeight); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlocksAtOrAboveHeight: %w", err) + } + if q.deleteExpiredUtxoLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredUtxoLeases: %w", err) + } if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) } if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.deleteUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, DeleteUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUnminedTransactionByHash: %w", err) + } + if q.deleteUtxosByTxIDStmt, err = db.PrepareContext(ctx, DeleteUtxosByTxID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUtxosByTxID: %w", err) + } if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) } @@ -72,6 +96,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { + return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) + } if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) } @@ -99,6 +126,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) } + if q.getTransactionByHashStmt, err = db.PrepareContext(ctx, GetTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionByHash: %w", err) + } + if q.getTransactionMetaByHashStmt, err = db.PrepareContext(ctx, GetTransactionMetaByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionMetaByHash: %w", err) + } + if q.getUtxoByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoByOutpoint: %w", err) + } + if q.getUtxoIDByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoIDByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoIDByOutpoint: %w", err) + } + if q.getUtxoSpendByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoSpendByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoSpendByOutpoint: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -117,6 +159,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) } + if q.insertTransactionStmt, err = db.PrepareContext(ctx, InsertTransaction); err != nil { + return nil, fmt.Errorf("error preparing query InsertTransaction: %w", err) + } + if q.insertTxReplacementEdgeStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdge); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdge: %w", err) + } + if q.insertTxReplacementEdgeByHashStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdgeByHash); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdgeByHash: %w", err) + } + if q.insertUtxoStmt, err = db.PrepareContext(ctx, InsertUtxo); err != nil { + return nil, fmt.Errorf("error preparing query InsertUtxo: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -135,6 +189,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) } + if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -144,15 +201,57 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } + if q.listReplacedTxHashesByReplacementTxHashStmt, err = db.PrepareContext(ctx, ListReplacedTxHashesByReplacementTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxHashesByReplacementTxHash: %w", err) + } + if q.listReplacedTxIDsByReplacementTxIDStmt, err = db.PrepareContext(ctx, ListReplacedTxIDsByReplacementTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxIDsByReplacementTxID: %w", err) + } + if q.listReplacementTxHashesByReplacedTxHashStmt, err = db.PrepareContext(ctx, ListReplacementTxHashesByReplacedTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxHashesByReplacedTxHash: %w", err) + } + if q.listReplacementTxIDsByReplacedTxIDStmt, err = db.PrepareContext(ctx, ListReplacementTxIDsByReplacedTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxIDsByReplacedTxID: %w", err) + } + if q.listRollbackCoinbaseRootsStmt, err = db.PrepareContext(ctx, ListRollbackCoinbaseRoots); err != nil { + return nil, fmt.Errorf("error preparing query ListRollbackCoinbaseRoots: %w", err) + } + if q.listSpendingTxIDsByParentTxIDStmt, err = db.PrepareContext(ctx, ListSpendingTxIDsByParentTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListSpendingTxIDsByParentTxID: %w", err) + } + if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) + } + if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) + } + if q.listUtxosStmt, err = db.PrepareContext(ctx, ListUtxos); err != nil { + return nil, fmt.Errorf("error preparing query ListUtxos: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.markUtxoSpentStmt, err = db.PrepareContext(ctx, MarkUtxoSpent); err != nil { + return nil, fmt.Errorf("error preparing query MarkUtxoSpent: %w", err) + } + if q.releaseUtxoLeaseStmt, err = db.PrepareContext(ctx, ReleaseUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query ReleaseUtxoLease: %w", err) + } + if q.rewindWalletSyncStateHeightsForRollbackStmt, err = db.PrepareContext(ctx, RewindWalletSyncStateHeightsForRollback); err != nil { + return nil, fmt.Errorf("error preparing query RewindWalletSyncStateHeightsForRollback: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) + } + if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -164,6 +263,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.acquireUtxoLeaseStmt != nil { + if cerr := q.acquireUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) + } + } + if q.balanceStmt != nil { + if cerr := q.balanceStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing balanceStmt: %w", cerr) + } + } + if q.clearUtxosSpentByTxIDStmt != nil { + if cerr := q.clearUtxosSpentByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) + } + } + if q.confirmUnminedTransactionByHashStmt != nil { + if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) + } + } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -209,6 +328,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteBlocksAtOrAboveHeightStmt != nil { + if cerr := q.deleteBlocksAtOrAboveHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlocksAtOrAboveHeightStmt: %w", cerr) + } + } + if q.deleteExpiredUtxoLeasesStmt != nil { + if cerr := q.deleteExpiredUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredUtxoLeasesStmt: %w", cerr) + } + } if q.deleteKeyScopeStmt != nil { if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) @@ -219,6 +348,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.deleteUnminedTransactionByHashStmt != nil { + if cerr := q.deleteUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUnminedTransactionByHashStmt: %w", cerr) + } + } + if q.deleteUtxosByTxIDStmt != nil { + if cerr := q.deleteUtxosByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUtxosByTxIDStmt: %w", cerr) + } + } if q.getAccountByScopeAndNameStmt != nil { if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) @@ -244,6 +383,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getActiveUtxoLeaseLockIDStmt != nil { + if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) + } + } if q.getAddressByScriptPubKeyStmt != nil { if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) @@ -289,6 +433,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) } } + if q.getTransactionByHashStmt != nil { + if cerr := q.getTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionByHashStmt: %w", cerr) + } + } + if q.getTransactionMetaByHashStmt != nil { + if cerr := q.getTransactionMetaByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionMetaByHashStmt: %w", cerr) + } + } + if q.getUtxoByOutpointStmt != nil { + if cerr := q.getUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoByOutpointStmt: %w", cerr) + } + } + if q.getUtxoIDByOutpointStmt != nil { + if cerr := q.getUtxoIDByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoIDByOutpointStmt: %w", cerr) + } + } + if q.getUtxoSpendByOutpointStmt != nil { + if cerr := q.getUtxoSpendByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoSpendByOutpointStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -319,6 +488,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) } } + if q.insertTransactionStmt != nil { + if cerr := q.insertTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTransactionStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeStmt != nil { + if cerr := q.insertTxReplacementEdgeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeByHashStmt != nil { + if cerr := q.insertTxReplacementEdgeByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeByHashStmt: %w", cerr) + } + } + if q.insertUtxoStmt != nil { + if cerr := q.insertUtxoStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertUtxoStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -349,6 +538,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveUtxoLeasesStmt != nil { + if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -364,11 +558,71 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listReplacedTxHashesByReplacementTxHashStmt != nil { + if cerr := q.listReplacedTxHashesByReplacementTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxHashesByReplacementTxHashStmt: %w", cerr) + } + } + if q.listReplacedTxIDsByReplacementTxIDStmt != nil { + if cerr := q.listReplacedTxIDsByReplacementTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxIDsByReplacementTxIDStmt: %w", cerr) + } + } + if q.listReplacementTxHashesByReplacedTxHashStmt != nil { + if cerr := q.listReplacementTxHashesByReplacedTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxHashesByReplacedTxHashStmt: %w", cerr) + } + } + if q.listReplacementTxIDsByReplacedTxIDStmt != nil { + if cerr := q.listReplacementTxIDsByReplacedTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxIDsByReplacedTxIDStmt: %w", cerr) + } + } + if q.listRollbackCoinbaseRootsStmt != nil { + if cerr := q.listRollbackCoinbaseRootsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listRollbackCoinbaseRootsStmt: %w", cerr) + } + } + if q.listSpendingTxIDsByParentTxIDStmt != nil { + if cerr := q.listSpendingTxIDsByParentTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSpendingTxIDsByParentTxIDStmt: %w", cerr) + } + } + if q.listTransactionsByHeightRangeStmt != nil { + if cerr := q.listTransactionsByHeightRangeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) + } + } + if q.listUnminedTransactionsStmt != nil { + if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) + } + } + if q.listUtxosStmt != nil { + if cerr := q.listUtxosStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUtxosStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.markUtxoSpentStmt != nil { + if cerr := q.markUtxoSpentStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing markUtxoSpentStmt: %w", cerr) + } + } + if q.releaseUtxoLeaseStmt != nil { + if cerr := q.releaseUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing releaseUtxoLeaseStmt: %w", cerr) + } + } + if q.rewindWalletSyncStateHeightsForRollbackStmt != nil { + if cerr := q.rewindWalletSyncStateHeightsForRollbackStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing rewindWalletSyncStateHeightsForRollbackStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -379,6 +633,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateTransactionLabelByHashStmt != nil { + if cerr := q.updateTransactionLabelByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) + } + } + if q.updateTransactionStatusByIDsStmt != nil { + if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -428,6 +692,10 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx + acquireUtxoLeaseStmt *sql.Stmt + balanceStmt *sql.Stmt + clearUtxosSpentByTxIDStmt *sql.Stmt + confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -437,13 +705,18 @@ type Queries struct { createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + deleteBlocksAtOrAboveHeightStmt *sql.Stmt + deleteExpiredUtxoLeasesStmt *sql.Stmt deleteKeyScopeStmt *sql.Stmt deleteKeyScopeSecretsStmt *sql.Stmt + deleteUnminedTransactionByHashStmt *sql.Stmt + deleteUtxosByTxIDStmt *sql.Stmt getAccountByScopeAndNameStmt *sql.Stmt getAccountByScopeAndNumberStmt *sql.Stmt getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt @@ -453,24 +726,48 @@ type Queries struct { getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt getKeyScopeSecretsStmt *sql.Stmt + getTransactionByHashStmt *sql.Stmt + getTransactionMetaByHashStmt *sql.Stmt + getUtxoByOutpointStmt *sql.Stmt + getUtxoIDByOutpointStmt *sql.Stmt + getUtxoSpendByOutpointStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt + insertTransactionStmt *sql.Stmt + insertTxReplacementEdgeStmt *sql.Stmt + insertTxReplacementEdgeByHashStmt *sql.Stmt + insertUtxoStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt listAccountsByScopeStmt *sql.Stmt listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listReplacedTxHashesByReplacementTxHashStmt *sql.Stmt + listReplacedTxIDsByReplacementTxIDStmt *sql.Stmt + listReplacementTxHashesByReplacedTxHashStmt *sql.Stmt + listReplacementTxIDsByReplacedTxIDStmt *sql.Stmt + listRollbackCoinbaseRootsStmt *sql.Stmt + listSpendingTxIDsByParentTxIDStmt *sql.Stmt + listTransactionsByHeightRangeStmt *sql.Stmt + listUnminedTransactionsStmt *sql.Stmt + listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt + markUtxoSpentStmt *sql.Stmt + releaseUtxoLeaseStmt *sql.Stmt + rewindWalletSyncStateHeightsForRollbackStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -479,6 +776,10 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, + acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + balanceStmt: q.balanceStmt, + clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, + confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -488,13 +789,18 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + deleteBlocksAtOrAboveHeightStmt: q.deleteBlocksAtOrAboveHeightStmt, + deleteExpiredUtxoLeasesStmt: q.deleteExpiredUtxoLeasesStmt, deleteKeyScopeStmt: q.deleteKeyScopeStmt, deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + deleteUnminedTransactionByHashStmt: q.deleteUnminedTransactionByHashStmt, + deleteUtxosByTxIDStmt: q.deleteUtxosByTxIDStmt, getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, @@ -504,24 +810,48 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getTransactionByHashStmt: q.getTransactionByHashStmt, + getTransactionMetaByHashStmt: q.getTransactionMetaByHashStmt, + getUtxoByOutpointStmt: q.getUtxoByOutpointStmt, + getUtxoIDByOutpointStmt: q.getUtxoIDByOutpointStmt, + getUtxoSpendByOutpointStmt: q.getUtxoSpendByOutpointStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertTransactionStmt: q.insertTransactionStmt, + insertTxReplacementEdgeStmt: q.insertTxReplacementEdgeStmt, + insertTxReplacementEdgeByHashStmt: q.insertTxReplacementEdgeByHashStmt, + insertUtxoStmt: q.insertUtxoStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, listAccountsByScopeStmt: q.listAccountsByScopeStmt, listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listReplacedTxHashesByReplacementTxHashStmt: q.listReplacedTxHashesByReplacementTxHashStmt, + listReplacedTxIDsByReplacementTxIDStmt: q.listReplacedTxIDsByReplacementTxIDStmt, + listReplacementTxHashesByReplacedTxHashStmt: q.listReplacementTxHashesByReplacedTxHashStmt, + listReplacementTxIDsByReplacedTxIDStmt: q.listReplacementTxIDsByReplacedTxIDStmt, + listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, + listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, + listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, + listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, + markUtxoSpentStmt: q.markUtxoSpentStmt, + releaseUtxoLeaseStmt: q.releaseUtxoLeaseStmt, + rewindWalletSyncStateHeightsForRollbackStmt: q.rewindWalletSyncStateHeightsForRollbackStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 6352775b72..a08fb8d7a7 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -77,6 +77,43 @@ type KeyScopeSecret struct { EncryptedCoinPrivKey []byte } +type Transaction struct { + WalletID int64 + ID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt64 + TxStatus int64 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +type TxReplacement struct { + WalletID int64 + ID int64 + ReplacedTxID int64 + ReplacementTxID int64 + CreatedAt time.Time +} + +type Utxo struct { + ID int64 + TxID int64 + OutputIndex int64 + Amount int64 + AddressID int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt64 +} + +type UtxoLease struct { + WalletID int64 + UtxoID int64 + LockID []byte + ExpiresAt time.Time +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index e55e93f16a..5c644cbb50 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -6,9 +6,73 @@ package sqlcsqlite import ( "context" + "database/sql" + "time" ) type Querier interface { + // Acquires or renews a lease for an outpoint and returns the resulting + // expiration time. + // + // How: + // - Resolves the outpoint to a current UTXO row and writes the lease in the + // same statement. + // - Rechecks that the outpoint is still unspent and its parent transaction is + // still in a live state (`pending` or `published`) at write time. + // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, + // and expired-lease takeover all happen atomically. + // Lease semantics: + // - If the UTXO has no lease row, insert a new lease. + // - If the UTXO has an expired lease, steal it. + // - If the UTXO has an active lease with the same lock_id, renew it. + // - If the UTXO has an active lease with a different lock_id, return no + // rows (caller should treat this as "already leased"). + // + // NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and + // callers must also pass a UTC-normalized now_utc for lease-expiry checks. + // Performance: + // - SQLite executes the resolution and lease write atomically inside one + // statement, which avoids stale-ID races between separate helper calls. + AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Returns the total and locked value represented by the wallet's current + // unspent UTXO set. + // + // How: + // - Starts from wallet-scoped unspent outputs and rejoins transactions plus + // wallet_sync_states for confirmation math. + // - Rejoins addresses -> accounts -> key_scopes so ownership validation and + // optional account filtering stay in one read. + // - Applies optional confirmation-range and coinbase-maturity policy directly + // inside the aggregate query so callers can request factual or policy-shaped + // balance reads through one public method. + // - Returns both the total matching value and the locked subset covered by + // active leases after the same filters are applied. + // Performance: + // - Executes as one aggregate over wallet-scoped live outputs. + // - Uses a filtered aggregate over active leases rather than issuing a second + // query for the locked subset. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) + // Clears spent_by pointers for all UTXOs spent by the provided transaction ID. + // + // How: + // - Resets both spent columns together so the logical spend pointer remains + // internally consistent. + // Performance: + // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet + // ownership through the creating transaction. + ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) + // Attaches a confirming block to one existing live unmined transaction row. + // + // How: + // - Updates only rows that are still blockless and live (`pending` or + // `published`). + // - Leaves user-visible metadata such as labels untouched so confirmation can + // reuse the original transaction row instead of reinserting it. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -32,10 +96,48 @@ type Querier interface { CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Deletes blocks at and after the provided height. + // + // How: + // - Deletes directly from blocks by the natural height key. + // - Relies on FK/trigger side effects to null transaction block references and + // orphan coinbase rows. + // Performance: + // - Executes as a range delete over the block-height primary key. + DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int64) (int64, error) + // Deletes all expired lease rows for a wallet. + // + // How: + // - Removes only rows whose expiration has passed, leaving active leases + // untouched. + // Performance: + // - Uses the expiration predicate together with wallet scoping to bound the + // cleanup pass. + DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) // Deletes a key scope by its ID. DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Deletes an unconfirmed transaction row. + // + // How: + // - Deletes only rows whose `block_height` is still NULL and whose status is + // still in a live unconfirmed state (`pending` or `published`). + // - Preserves orphaned/replaced/failed history; those rows must remain visible + // for audit/reorg handling instead of being treated as ordinary mempool data. + // - The caller must delete or restore dependent UTXO rows first. + // Performance: + // - Targets at most one row by `(wallet_id, tx_hash)`. + DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) + // Deletes all UTXO rows created by the provided transaction ID. + // + // How: + // - Removes outputs by the parent transaction's internal ID after callers have + // already decided the transaction row itself may be deleted. + // Performance: + // - Uses the `(tx_id)` index to keep the delete bounded to one transaction's + // outputs, then rechecks wallet ownership through the parent transaction. + DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) // Returns a single account by scope id and account name. GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) // Returns a single account by scope id and account number. @@ -46,6 +148,14 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns the lock ID for the current active lease on a UTXO ID. + // + // How: + // - Reads only non-expired lease rows so callers can distinguish an active + // lock-ID mismatch from an already-unlocked output. + // Performance: + // - Targets at most one row through the unique lease key. + GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) // Retrieves an address by its script pubkey and account wallet. GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -68,6 +178,66 @@ type Querier interface { GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) + // Retrieves the full transaction row along with optional block metadata. + // + // How: + // - Looks up the transaction by `(wallet_id, tx_hash)`. + // - LEFT JOINs blocks on `block_height` so the same query handles mined and + // unmined rows. + // Performance: + // - The unique transaction lookup limits the join fanout to at most one block + // row. + GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) + // Retrieves the primary key and lightweight transaction metadata. + // + // How: + // - Reads only the transactions table because callers only need row identity + // plus lightweight status/label fields. + // Performance: + // - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. + GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) + // Retrieves a single unspent UTXO by its outpoint. + // + // How: + // - Joins utxos -> transactions on `tx_id` to resolve the outpoint + // from tx hash plus output index. + // - Joins addresses -> accounts -> key_scopes so the read path reasserts that + // the credited address belongs to the requested wallet. + // - Returns leased and unleased outputs alike because leasing affects coin + // selection, not whether the UTXO exists. + // - Treats outputs from both live unconfirmed parent states (`pending` and + // `published`) as part of the wallet's current UTXO set. + // Performance: + // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the + // join fanout to at most one candidate output. + GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) + // Retrieves the database ID for a current UTXO by its outpoint. + // + // How: + // - Joins transactions on `id` so callers can address a UTXO by + // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. + // - Restricts the result to unspent outputs whose parent transaction is still + // in a live state (`pending` or `published`). + // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return + // rows whose credited address does not actually belong to the wallet. + // - Exists separately from GetUtxoByOutpoint because mutation helpers often + // need the stable internal UTXO row ID without reading the full public UTXO + // payload. + // Performance: + // - Uses the wallet-scoped transaction hash lookup first, then narrows to the + // unique `(tx_id, output_index)` outpoint. + GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) + // Returns the current spend edge for one wallet-owned outpoint. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only + // considers outputs whose parent status is `pending` or `published`. + // - Returns the nullable `spent_by_tx_id` column so callers can distinguish + // between an external/dead parent and a wallet-owned conflict. + // Performance: + // - Targets one wallet-scoped outpoint through the unique `(tx_id, + // output_index)` key after the parent hash lookup. + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) @@ -78,6 +248,51 @@ type Querier interface { // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error + // Inserts a wallet-scoped transaction row and returns its database ID. + // + // How: + // - Writes only the transactions table. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. + // - Expects the caller to supply the initial status explicitly so unmined rows + // do not have to guess between `pending` and `published`. + // Performance: + // - Single-row insert. The cost is dominated by the wallet/hash uniqueness + // checks and any optional block foreign-key validation. + InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) + // Records a replacement edge between two wallet-scoped transactions. + // + // How: + // - Writes directly to tx_replacements using already-resolved transaction IDs. + // - Uses an explicit conflict target so only duplicate edges are ignored; + // missing transaction IDs, self-replacements, and other constraint failures + // still surface to the caller. + // Performance: + // - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. + InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) + // Records a replacement edge by resolving tx IDs from transaction hashes. + // + // How: + // - Resolves both endpoint transaction IDs from the transactions table using + // the wallet-scoped tx-hash unique lookup. + // - Writes the resulting directed edge to tx_replacements. + // - Uses an explicit conflict target so duplicate-edge retries are ignored + // without masking missing-hash or check-constraint failures. + // Performance: + // - Trades two indexed scalar subqueries for one network round trip, which is + // preferable when callers start from tx hashes. + InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) + // Inserts a new UTXO row and returns its database ID. + // + // How: + // - Writes only the utxos table using already-resolved transaction and address + // IDs. + // - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if + // the provided address ID belongs to the same wallet. + // Performance: + // - Single-row insert. The main cost is the wallet-ownership validation join + // plus FK and uniqueness checks. + InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -92,6 +307,18 @@ type Querier interface { // Lists all accounts for a wallet and scope tuple, ordered by account number. // Imported accounts (with NULL account_number) appear last. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists all currently active leases for a wallet. + // + // How: + // - Starts from utxo_leases, then joins utxos and transactions so the result + // can be returned as network outpoints. + // - Filters out expired rows using the caller-supplied UTC timestamp. + // - Restricts the result to outputs that are still unspent and whose parent + // transaction is still in a live state (`pending` or `published`). + // Performance: + // - Restricts first by wallet and expiration, then joins only the surviving + // lease rows back to utxos/transactions. + ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all addresses for a given account identified by wallet_id, key scope @@ -100,11 +327,175 @@ type Querier interface { ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + // Lists victim txids for a given replacement txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the replacement hash on the `replacement` alias. + // Performance: + // - Mirrors the victim lookup path while keeping the graph traversal bounded by + // wallet scope and indexed edge keys. + ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) + // Lists victim transaction IDs for a given replacement transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because + // the caller already has the replacement row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the inverse replacement lookup index without joining transactions. + ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) + // Lists replacement txids for a given victim txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the victim hash on the `replaced` alias. + // Performance: + // - The victim hash lookup narrows the graph walk before the second transaction + // join materializes replacement hashes. + ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) + // Lists replacement transaction IDs for a given victim transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the + // caller already has the victim's internal row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the replacement-edge index without joining transactions. + ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) + // Lists wallet-scoped coinbase transaction hashes at or above the rollback + // boundary that seed descendant invalidation. + // + // How: + // - Reads only confirmed coinbase rows at or above the rollback boundary. + // - Returns wallet scope alongside each tx hash so callers can treat these + // coinbase transactions as rollback roots when invalidating now-dead + // descendants inside the same rollback transaction. + // - This is a rollback-specific helper, not a generic "coinbase txs from one + // block" listing query. + // Performance: + // - Uses the block-height index to bound the scan to the rollback range. + ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int64) ([]ListRollbackCoinbaseRootsRow, error) + // Lists direct child transaction IDs for one parent transaction ID. + // + // How: + // - Reads the spend edges already materialized on utxos through + // `(tx_id, spent_by_tx_id)`. + // - Returns only direct children; callers that need full descendant walks should + // traverse this query iteratively in application code. + // Performance: + // - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded + // to one wallet-scoped parent. + ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) + // Lists all confirmed transactions for a wallet in the provided height range. + // + // How: + // - Reads transactions in a wallet-scoped block-height range. + // - INNER JOINs blocks on the natural `block_height` key to hydrate block hash + // and timestamp for confirmed rows. + // Performance: + // - The `(wallet_id, block_height)` index bounds the scan before the single-row + // block join. + ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists all unconfirmed transactions for a wallet. + // + // How: + // - Reads from transactions only and filters on blockless rows that are still + // in a live unconfirmed state (`pending` or `published`). + // - Excludes orphaned/replaced/failed history so rollback-produced coinbase + // rows do not reappear as mempool transactions. + // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` + // so the unmined row shape stays aligned with the confirmed query below. + // Performance: + // - Matches the dedicated blockless-history index while the more selective + // live-only partial index stays available for conflict paths. + ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) + // Lists unspent UTXOs that match the provided filters. + // + // How: + // - Starts from utxos and joins transactions for tx metadata plus + // wallet_sync_states for confirmation math. + // - Joins addresses to return the required script_pub_key, then reuses + // addresses -> accounts -> key_scopes so account filtering and wallet + // ownership checks happen in the same read. + // - Returns leased outputs too because the API models leases separately from + // UTXO existence. + // - Includes outputs whose parent transaction is still in a live unconfirmed + // state (`pending` or `published`). + // - Intentionally does not enforce coinbase maturity because this query models + // wallet-owned UTXO existence rather than a strictly spendable subset. + // Performance: + // - Restricts first by wallet, spend state, and transaction status. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + // - Treats min/max confirmations as optional filters so callers can + // distinguish "not set" from an explicit zero-conf request. + ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Marks a wallet-owned UTXO as spent by a transaction. + // + // How: + // - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside + // the statement so callers can update by network outpoint. + // - Requires the parent transaction status to be `pending` or `published` + // before a child spend edge can attach. + // - Only changes rows that are currently unspent or already point at the same + // `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent + // without allowing a caller to silently rewrite which input spent the UTXO. + // Performance: + // - Targets one outpoint in one wallet; the subquery uses the unique + // wallet-scoped tx hash lookup. + MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) + // Releases a lease for a UTXO ID if the lock_id matches. + // + // How: + // - Deletes by wallet, utxo ID, and lock ID so one caller cannot release + // another caller's active lease accidentally. + // Performance: + // - Targets at most one row through the unique lease key. + ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) + // Rewrites wallet sync-state heights so they stop referencing blocks that are + // about to be deleted during RollbackToBlock. + // + // How: + // - Updates wallet_sync_states directly without joining other tables. + // - Rewrites both synced_height and birthday_height in one statement so the + // subsequent block delete does not violate `ON DELETE RESTRICT`. + // - Example: if `rollback_height = 195`, then any `synced_height` or + // `birthday_height` at 195 or above rewinds to `new_height = 194`. + // - If rollback starts from height 0, callers pass `new_height = NULL` so the + // sync state no longer points at any surviving block row. + // Performance: + // - Touches only wallet_sync_states rows whose heights are at or above the + // rollback boundary. + RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates only the user-visible transaction label. + // + // How: + // - Leaves block assignment and status untouched. + // - Exists for user-facing metadata edits only; wallet-internal state + // transitions use dedicated helper queries. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the wallet-relative status for a set of transaction row IDs. + // + // How: + // - Exists for wallet-internal replacement and invalidation flows after the + // caller has already identified the affected rows. + // - Leaves block assignment untouched; rollback/disconnect continues to use the + // dedicated rewind helpers below. + // Performance: + // - Restricts by wallet scope first, then matches only the provided ID set. + UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go new file mode 100644 index 0000000000..a6b28a0f11 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -0,0 +1,616 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: transactions.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "strings" + "time" +) + +const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows +UPDATE transactions +SET + block_height = cast(?1 AS INTEGER), + tx_status = 1 +WHERE + wallet_id = ?2 + AND tx_hash = ?3 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type ConfirmUnminedTransactionByHashParams struct { + BlockHeight int64 + WalletID int64 + TxHash []byte +} + +// Attaches a confirming block to one existing live unmined transaction row. +// +// How: +// - Updates only rows that are still blockless and live (`pending` or +// `published`). +// - Leaves user-visible metadata such as labels untouched so confirmation can +// reuse the original transaction row instead of reinserting it. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows +DELETE FROM blocks +WHERE block_height >= ? +` + +// Deletes blocks at and after the provided height. +// +// How: +// - Deletes directly from blocks by the natural height key. +// - Relies on FK/trigger side effects to null transaction block references and +// orphan coinbase rows. +// +// Performance: +// - Executes as a range delete over the block-height primary key. +func (q *Queries) DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int64) (int64, error) { + result, err := q.exec(ctx, q.deleteBlocksAtOrAboveHeightStmt, DeleteBlocksAtOrAboveHeight, blockHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUnminedTransactionByHash = `-- name: DeleteUnminedTransactionByHash :execrows +DELETE FROM transactions +WHERE + wallet_id = ? + AND tx_hash = ? + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type DeleteUnminedTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +// Deletes an unconfirmed transaction row. +// +// How: +// - Deletes only rows whose `block_height` is still NULL and whose status is +// still in a live unconfirmed state (`pending` or `published`). +// - Preserves orphaned/replaced/failed history; those rows must remain visible +// for audit/reorg handling instead of being treated as ordinary mempool data. +// - The caller must delete or restore dependent UTXO rows first. +// +// Performance: +// - Targets at most one row by `(wallet_id, tx_hash)`. +func (q *Queries) DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUnminedTransactionByHashStmt, DeleteUnminedTransactionByHash, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetTransactionByHash = `-- name: GetTransactionByHash :one +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = ? AND t.tx_hash = ? +` + +type GetTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionByHashRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Retrieves the full transaction row along with optional block metadata. +// +// How: +// - Looks up the transaction by `(wallet_id, tx_hash)`. +// - LEFT JOINs blocks on `block_height` so the same query handles mined and +// unmined rows. +// +// Performance: +// - The unique transaction lookup limits the join fanout to at most one block +// row. +func (q *Queries) GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionByHashStmt, GetTransactionByHash, arg.WalletID, arg.TxHash) + var i GetTransactionByHashRow + err := row.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const GetTransactionMetaByHash = `-- name: GetTransactionMetaByHash :one +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = ? AND tx_hash = ? +` + +type GetTransactionMetaByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionMetaByHashRow struct { + ID int64 + BlockHeight sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Retrieves the primary key and lightweight transaction metadata. +// +// How: +// - Reads only the transactions table because callers only need row identity +// plus lightweight status/label fields. +// +// Performance: +// - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +func (q *Queries) GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionMetaByHashStmt, GetTransactionMetaByHash, arg.WalletID, arg.TxHash) + var i GetTransactionMetaByHashRow + err := row.Scan( + &i.ID, + &i.BlockHeight, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const InsertTransaction = `-- name: InsertTransaction :one +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type InsertTransactionParams struct { + WalletID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt64 + TxStatus int64 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +// Inserts a wallet-scoped transaction row and returns its database ID. +// +// How: +// - Writes only the transactions table. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. +// - Expects the caller to supply the initial status explicitly so unmined rows +// do not have to guess between `pending` and `published`. +// +// Performance: +// - Single-row insert. The cost is dominated by the wallet/hash uniqueness +// checks and any optional block foreign-key validation. +func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { + row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, + arg.WalletID, + arg.TxHash, + arg.RawTx, + arg.BlockHeight, + arg.TxStatus, + arg.ReceivedTime, + arg.IsCoinbase, + arg.TxLabel, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListRollbackCoinbaseRoots = `-- name: ListRollbackCoinbaseRoots :many +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= cast(?1 AS INTEGER) + AND is_coinbase +ORDER BY wallet_id, id +` + +type ListRollbackCoinbaseRootsRow struct { + WalletID int64 + TxHash []byte +} + +// Lists wallet-scoped coinbase transaction hashes at or above the rollback +// boundary that seed descendant invalidation. +// +// How: +// - Reads only confirmed coinbase rows at or above the rollback boundary. +// - Returns wallet scope alongside each tx hash so callers can treat these +// coinbase transactions as rollback roots when invalidating now-dead +// descendants inside the same rollback transaction. +// - This is a rollback-specific helper, not a generic "coinbase txs from one +// block" listing query. +// +// Performance: +// - Uses the block-height index to bound the scan to the rollback range. +func (q *Queries) ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int64) ([]ListRollbackCoinbaseRootsRow, error) { + rows, err := q.query(ctx, q.listRollbackCoinbaseRootsStmt, ListRollbackCoinbaseRoots, rollbackHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRollbackCoinbaseRootsRow + for rows.Next() { + var i ListRollbackCoinbaseRootsRow + if err := rows.Scan(&i.WalletID, &i.TxHash); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListTransactionsByHeightRange = `-- name: ListTransactionsByHeightRange :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = ?1 + AND t.block_height >= cast(?2 AS INTEGER) + AND t.block_height <= cast(?3 AS INTEGER) +ORDER BY t.block_height, t.id +` + +type ListTransactionsByHeightRangeParams struct { + WalletID int64 + StartHeight int64 + EndHeight int64 +} + +type ListTransactionsByHeightRangeRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp int64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists all confirmed transactions for a wallet in the provided height range. +// +// How: +// - Reads transactions in a wallet-scoped block-height range. +// - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +// and timestamp for confirmed rows. +// +// Performance: +// - The `(wallet_id, block_height)` index bounds the scan before the single-row +// block join. +func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) { + rows, err := q.query(ctx, q.listTransactionsByHeightRangeStmt, ListTransactionsByHeightRange, arg.WalletID, arg.StartHeight, arg.EndHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsByHeightRangeRow + for rows.Next() { + var i ListTransactionsByHeightRangeRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUnminedTransactions = `-- name: ListUnminedTransactions :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC +` + +type ListUnminedTransactionsRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists all unconfirmed transactions for a wallet. +// +// How: +// - Reads from transactions only and filters on blockless rows that are still +// in a live unconfirmed state (`pending` or `published`). +// - Excludes orphaned/replaced/failed history so rollback-produced coinbase +// rows do not reappear as mempool transactions. +// - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +// so the unmined row shape stays aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated blockless-history index while the more selective +// live-only partial index stays available for conflict paths. +func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { + rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedTransactionsRow + for rows.Next() { + var i ListUnminedTransactionsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RewindWalletSyncStateHeightsForRollback = `-- name: RewindWalletSyncStateHeightsForRollback :execrows +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= cast(?1 AS INTEGER) + THEN ?2 + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= cast(?1 AS INTEGER) + THEN ?2 + ELSE birthday_height + END, + updated_at = current_timestamp +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= cast(?1 AS INTEGER) + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= cast(?1 AS INTEGER) + ) +` + +type RewindWalletSyncStateHeightsForRollbackParams struct { + RollbackHeight int64 + NewHeight sql.NullInt64 +} + +// Rewrites wallet sync-state heights so they stop referencing blocks that are +// about to be deleted during RollbackToBlock. +// +// How: +// - Updates wallet_sync_states directly without joining other tables. +// - Rewrites both synced_height and birthday_height in one statement so the +// subsequent block delete does not violate `ON DELETE RESTRICT`. +// - Example: if `rollback_height = 195`, then any `synced_height` or +// `birthday_height` at 195 or above rewinds to `new_height = 194`. +// - If rollback starts from height 0, callers pass `new_height = NULL` so the +// sync state no longer points at any surviving block row. +// +// Performance: +// - Touches only wallet_sync_states rows whose heights are at or above the +// rollback boundary. +func (q *Queries) RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) { + result, err := q.exec(ctx, q.rewindWalletSyncStateHeightsForRollbackStmt, RewindWalletSyncStateHeightsForRollback, arg.RollbackHeight, arg.NewHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionLabelByHash = `-- name: UpdateTransactionLabelByHash :execrows +UPDATE transactions +SET tx_label = ?1 +WHERE + wallet_id = ?2 + AND tx_hash = ?3 +` + +type UpdateTransactionLabelByHashParams struct { + Label string + WalletID int64 + TxHash []byte +} + +// Updates only the user-visible transaction label. +// +// How: +// - Leaves block assignment and status untouched. +// - Exists for user-facing metadata edits only; wallet-internal state +// transitions use dedicated helper queries. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionLabelByHashStmt, UpdateTransactionLabelByHash, arg.Label, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows +UPDATE transactions +SET tx_status = ?1 +WHERE + wallet_id = ?2 + AND id IN (/*SLICE:tx_ids*/?) +` + +type UpdateTransactionStatusByIDsParams struct { + Status int64 + WalletID int64 + TxIds []int64 +} + +// Updates the wallet-relative status for a set of transaction row IDs. +// +// How: +// - Exists for wallet-internal replacement and invalidation flows after the +// caller has already identified the affected rows. +// - Leaves block assignment untouched; rollback/disconnect continues to use the +// dedicated rewind helpers below. +// +// Performance: +// - Restricts by wallet scope first, then matches only the provided ID set. +func (q *Queries) UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) { + query := UpdateTransactionStatusByIDs + var queryParams []interface{} + queryParams = append(queryParams, arg.Status) + queryParams = append(queryParams, arg.WalletID) + if len(arg.TxIds) > 0 { + for _, v := range arg.TxIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:tx_ids*/?", strings.Repeat(",?", len(arg.TxIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:tx_ids*/?", "NULL", 1) + } + result, err := q.exec(ctx, nil, query, queryParams...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go b/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go new file mode 100644 index 0000000000..a4a2cd60ce --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go @@ -0,0 +1,313 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: tx_replacements.sql + +package sqlcsqlite + +import ( + "context" + "time" +) + +const InsertTxReplacementEdge = `-- name: InsertTxReplacementEdge :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, ?2, ?3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeParams struct { + WalletID int64 + ReplacedTxID int64 + ReplacementTxID int64 +} + +// Records a replacement edge between two wallet-scoped transactions. +// +// How: +// - Writes directly to tx_replacements using already-resolved transaction IDs. +// - Uses an explicit conflict target so only duplicate edges are ignored; +// missing transaction IDs, self-replacements, and other constraint failures +// still surface to the caller. +// +// Performance: +// - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +func (q *Queries) InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeStmt, InsertTxReplacementEdge, arg.WalletID, arg.ReplacedTxID, arg.ReplacementTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const InsertTxReplacementEdgeByHash = `-- name: InsertTxReplacementEdgeByHash :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeByHashParams struct { + WalletID int64 + TxHash []byte + TxHash_2 []byte +} + +// Records a replacement edge by resolving tx IDs from transaction hashes. +// +// How: +// - Resolves both endpoint transaction IDs from the transactions table using +// the wallet-scoped tx-hash unique lookup. +// - Writes the resulting directed edge to tx_replacements. +// - Uses an explicit conflict target so duplicate-edge retries are ignored +// without masking missing-hash or check-constraint failures. +// +// Performance: +// - Trades two indexed scalar subqueries for one network round trip, which is +// preferable when callers start from tx hashes. +func (q *Queries) InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeByHashStmt, InsertTxReplacementEdgeByHash, arg.WalletID, arg.TxHash, arg.TxHash_2) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const ListReplacedTxHashesByReplacementTxHash = `-- name: ListReplacedTxHashesByReplacementTxHash :many +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replacement.tx_hash = ?2 +ORDER BY r.created_at, r.id +` + +type ListReplacedTxHashesByReplacementTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacedTxHashesByReplacementTxHashRow struct { + ReplacedTxHash []byte + CreatedAt time.Time +} + +// Lists victim txids for a given replacement txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the replacement hash on the `replacement` alias. +// +// Performance: +// - Mirrors the victim lookup path while keeping the graph traversal bounded by +// wallet scope and indexed edge keys. +func (q *Queries) ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacedTxHashesByReplacementTxHashStmt, ListReplacedTxHashesByReplacementTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxHashesByReplacementTxHashRow + for rows.Next() { + var i ListReplacedTxHashesByReplacementTxHashRow + if err := rows.Scan(&i.ReplacedTxHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacedTxIDsByReplacementTxID = `-- name: ListReplacedTxIDsByReplacementTxID :many +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replacement_tx_id = ?2 +ORDER BY created_at, id +` + +type ListReplacedTxIDsByReplacementTxIDParams struct { + WalletID int64 + ReplacementTxID int64 +} + +type ListReplacedTxIDsByReplacementTxIDRow struct { + ReplacedTxID int64 + CreatedAt time.Time +} + +// Lists victim transaction IDs for a given replacement transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +// the caller already has the replacement row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the inverse replacement lookup index without joining transactions. +func (q *Queries) ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacedTxIDsByReplacementTxIDStmt, ListReplacedTxIDsByReplacementTxID, arg.WalletID, arg.ReplacementTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxIDsByReplacementTxIDRow + for rows.Next() { + var i ListReplacedTxIDsByReplacementTxIDRow + if err := rows.Scan(&i.ReplacedTxID, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacementTxHashesByReplacedTxHash = `-- name: ListReplacementTxHashesByReplacedTxHash :many +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replaced.tx_hash = ?2 +ORDER BY r.created_at, r.id +` + +type ListReplacementTxHashesByReplacedTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacementTxHashesByReplacedTxHashRow struct { + ReplacementTxHash []byte + CreatedAt time.Time +} + +// Lists replacement txids for a given victim txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the victim hash on the `replaced` alias. +// +// Performance: +// - The victim hash lookup narrows the graph walk before the second transaction +// join materializes replacement hashes. +func (q *Queries) ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacementTxHashesByReplacedTxHashStmt, ListReplacementTxHashesByReplacedTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxHashesByReplacedTxHashRow + for rows.Next() { + var i ListReplacementTxHashesByReplacedTxHashRow + if err := rows.Scan(&i.ReplacementTxHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListReplacementTxIDsByReplacedTxID = `-- name: ListReplacementTxIDsByReplacedTxID :many +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replaced_tx_id = ?2 +ORDER BY created_at, id +` + +type ListReplacementTxIDsByReplacedTxIDParams struct { + WalletID int64 + ReplacedTxID int64 +} + +type ListReplacementTxIDsByReplacedTxIDRow struct { + ReplacementTxID int64 + CreatedAt time.Time +} + +// Lists replacement transaction IDs for a given victim transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +// caller already has the victim's internal row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the replacement-edge index without joining transactions. +func (q *Queries) ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacementTxIDsByReplacedTxIDStmt, ListReplacementTxIDsByReplacedTxID, arg.WalletID, arg.ReplacedTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxIDsByReplacedTxIDRow + for rows.Next() { + var i ListReplacementTxIDsByReplacedTxIDRow + if err := rows.Scan(&i.ReplacementTxID, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go new file mode 100644 index 0000000000..d5ece2d6ac --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -0,0 +1,248 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxo_leases.sql + +package sqlcsqlite + +import ( + "context" + "time" +) + +const AcquireUtxoLease = `-- name: AcquireUtxoLease :one +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + ?1 AS wallet_id, + u.id AS utxo_id, + ?2 AS lock_id, + ?3 AS expires_at +FROM utxos AS u +INNER JOIN transactions AS t + ON u.tx_id = t.id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?4 + AND u.output_index = ?5 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ?6 IS NOT NULL +ON CONFLICT (utxo_id) DO UPDATE +SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at +WHERE +utxo_leases.wallet_id = excluded.wallet_id +AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc') + OR utxo_leases.lock_id = excluded.lock_id +) +RETURNING expires_at +` + +type AcquireUtxoLeaseParams struct { + WalletID int64 + LockID []byte + ExpiresAt time.Time + TxHash []byte + OutputIndex int64 + NowUtc interface{} +} + +// Acquires or renews a lease for an outpoint and returns the resulting +// expiration time. +// +// How: +// - Resolves the outpoint to a current UTXO row and writes the lease in the +// same statement. +// - Rechecks that the outpoint is still unspent and its parent transaction is +// still in a live state (`pending` or `published`) at write time. +// - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +// and expired-lease takeover all happen atomically. +// +// Lease semantics: +// - If the UTXO has no lease row, insert a new lease. +// - If the UTXO has an expired lease, steal it. +// - If the UTXO has an active lease with the same lock_id, renew it. +// - If the UTXO has an active lease with a different lock_id, return no +// rows (caller should treat this as "already leased"). +// +// NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and +// callers must also pass a UTC-normalized now_utc for lease-expiry checks. +// Performance: +// - SQLite executes the resolution and lease write atomically inside one +// statement, which avoids stale-ID races between separate helper calls. +func (q *Queries) AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) { + row := q.queryRow(ctx, q.acquireUtxoLeaseStmt, AcquireUtxoLease, + arg.WalletID, + arg.LockID, + arg.ExpiresAt, + arg.TxHash, + arg.OutputIndex, + arg.NowUtc, + ) + var expires_at time.Time + err := row.Scan(&expires_at) + return expires_at, err +} + +const DeleteExpiredUtxoLeases = `-- name: DeleteExpiredUtxoLeases :execrows +DELETE FROM utxo_leases +WHERE wallet_id = ?1 AND expires_at <= ?2 +` + +type DeleteExpiredUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +// Deletes all expired lease rows for a wallet. +// +// How: +// - Removes only rows whose expiration has passed, leaving active leases +// untouched. +// +// Performance: +// - Uses the expiration predicate together with wallet scoping to bound the +// cleanup pass. +func (q *Queries) DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) { + result, err := q.exec(ctx, q.deleteExpiredUtxoLeasesStmt, DeleteExpiredUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetActiveUtxoLeaseLockID = `-- name: GetActiveUtxoLeaseLockID :one +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = ?1 + AND utxo_id = ?2 + AND expires_at > ?3 +` + +type GetActiveUtxoLeaseLockIDParams struct { + WalletID int64 + UtxoID int64 + NowUtc time.Time +} + +// Returns the lock ID for the current active lease on a UTXO ID. +// +// How: +// - Reads only non-expired lease rows so callers can distinguish an active +// lock-ID mismatch from an already-unlocked output. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) { + row := q.queryRow(ctx, q.getActiveUtxoLeaseLockIDStmt, GetActiveUtxoLeaseLockID, arg.WalletID, arg.UtxoID, arg.NowUtc) + var lock_id []byte + err := row.Scan(&lock_id) + return lock_id, err +} + +const ListActiveUtxoLeases = `-- name: ListActiveUtxoLeases :many +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = ?1 + AND l.expires_at > ?2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at +` + +type ListActiveUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +type ListActiveUtxoLeasesRow struct { + TxHash []byte + OutputIndex int64 + LockID []byte + ExpiresAt time.Time +} + +// Lists all currently active leases for a wallet. +// +// How: +// - Starts from utxo_leases, then joins utxos and transactions so the result +// can be returned as network outpoints. +// - Filters out expired rows using the caller-supplied UTC timestamp. +// - Restricts the result to outputs that are still unspent and whose parent +// transaction is still in a live state (`pending` or `published`). +// +// Performance: +// - Restricts first by wallet and expiration, then joins only the surviving +// lease rows back to utxos/transactions. +func (q *Queries) ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) { + rows, err := q.query(ctx, q.listActiveUtxoLeasesStmt, ListActiveUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUtxoLeasesRow + for rows.Next() { + var i ListActiveUtxoLeasesRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.LockID, + &i.ExpiresAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ReleaseUtxoLease = `-- name: ReleaseUtxoLease :execrows +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = ?1 + AND utxo_leases.utxo_id = ?2 + AND utxo_leases.lock_id = ?3 +` + +type ReleaseUtxoLeaseParams struct { + WalletID int64 + UtxoID int64 + LockID []byte +} + +// Releases a lease for a UTXO ID if the lock_id matches. +// +// How: +// - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +// another caller's active lease accidentally. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) { + result, err := q.exec(ctx, q.releaseUtxoLeaseStmt, ReleaseUtxoLease, arg.WalletID, arg.UtxoID, arg.LockID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go new file mode 100644 index 0000000000..101a153c09 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -0,0 +1,643 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxos.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const Balance = `-- name: Balance :one +SELECT + cast(coalesce(sum(u.amount), 0) AS INTEGER) AS total_balance, + cast( + coalesce( + sum( + CASE + WHEN EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > ?1 + ) THEN u.amount + ELSE 0 + END + ), + 0 + ) AS INTEGER + ) AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = ?2 + AND ks.wallet_id = ?2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + ?3 IS NULL + OR acc.account_number = ?3 + ) + AND ( + ?4 IS NULL + OR ?4 = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?4 + ) + AND ( + ?5 IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= ?5 + ) + AND ( + ?6 IS NULL + OR ?6 = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?6 + ) +` + +type BalanceParams struct { + NowUtc time.Time + WalletID int64 + AccountNumber interface{} + MinConfirms interface{} + MaxConfirms interface{} + CoinbaseMaturity interface{} +} + +type BalanceRow struct { + TotalBalance int64 + LockedBalance int64 +} + +// Returns the total and locked value represented by the wallet's current +// unspent UTXO set. +// +// How: +// - Starts from wallet-scoped unspent outputs and rejoins transactions plus +// wallet_sync_states for confirmation math. +// - Rejoins addresses -> accounts -> key_scopes so ownership validation and +// optional account filtering stay in one read. +// - Applies optional confirmation-range and coinbase-maturity policy directly +// inside the aggregate query so callers can request factual or policy-shaped +// balance reads through one public method. +// - Returns both the total matching value and the locked subset covered by +// active leases after the same filters are applied. +// +// Performance: +// - Executes as one aggregate over wallet-scoped live outputs. +// - Uses a filtered aggregate over active leases rather than issuing a second +// query for the locked subset. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +func (q *Queries) Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) { + row := q.queryRow(ctx, q.balanceStmt, Balance, + arg.NowUtc, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + arg.CoinbaseMaturity, + ) + var i BalanceRow + err := row.Scan(&i.TotalBalance, &i.LockedBalance) + return i, err +} + +const ClearUtxosSpentByTxID = `-- name: ClearUtxosSpentByTxID :execrows +UPDATE utxos +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + spent_by_tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ) +` + +type ClearUtxosSpentByTxIDParams struct { + WalletID int64 + SpentByTxID sql.NullInt64 +} + +// Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +// +// How: +// - Resets both spent columns together so the logical spend pointer remains +// internally consistent. +// +// Performance: +// - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +// ownership through the creating transaction. +func (q *Queries) ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.clearUtxosSpentByTxIDStmt, ClearUtxosSpentByTxID, arg.WalletID, arg.SpentByTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUtxosByTxID = `-- name: DeleteUtxosByTxID :execrows +DELETE FROM utxos +WHERE + tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ) +` + +type DeleteUtxosByTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Deletes all UTXO rows created by the provided transaction ID. +// +// How: +// - Removes outputs by the parent transaction's internal ID after callers have +// already decided the transaction row itself may be deleted. +// +// Performance: +// - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +// outputs, then rechecks wallet ownership through the parent transaction. +func (q *Queries) DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUtxosByTxIDStmt, DeleteUtxosByTxID, arg.WalletID, arg.TxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetUtxoByOutpoint = `-- name: GetUtxoByOutpoint :one +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +type GetUtxoByOutpointRow struct { + TxHash []byte + OutputIndex int64 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt64 +} + +// Retrieves a single unspent UTXO by its outpoint. +// +// How: +// - Joins utxos -> transactions on `tx_id` to resolve the outpoint +// from tx hash plus output index. +// - Joins addresses -> accounts -> key_scopes so the read path reasserts that +// the credited address belongs to the requested wallet. +// - Returns leased and unleased outputs alike because leasing affects coin +// selection, not whether the UTXO exists. +// - Treats outputs from both live unconfirmed parent states (`pending` and +// `published`) as part of the wallet's current UTXO set. +// +// Performance: +// - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +// join fanout to at most one candidate output. +func (q *Queries) GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) { + row := q.queryRow(ctx, q.getUtxoByOutpointStmt, GetUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var i GetUtxoByOutpointRow + err := row.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ) + return i, err +} + +const GetUtxoIDByOutpoint = `-- name: GetUtxoIDByOutpoint :one +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoIDByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Retrieves the database ID for a current UTXO by its outpoint. +// +// How: +// - Joins transactions on `id` so callers can address a UTXO by +// network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +// - Restricts the result to unspent outputs whose parent transaction is still +// in a live state (`pending` or `published`). +// - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +// rows whose credited address does not actually belong to the wallet. +// - Exists separately from GetUtxoByOutpoint because mutation helpers often +// need the stable internal UTXO row ID without reading the full public UTXO +// payload. +// +// Performance: +// - Uses the wallet-scoped transaction hash lookup first, then narrows to the +// unique `(tx_id, output_index)` outpoint. +func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) { + row := q.queryRow(ctx, q.getUtxoIDByOutpointStmt, GetUtxoIDByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one +SELECT utxos.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos ON t.id = utxos.tx_id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status IN (0, 1) +` + +type GetUtxoSpendByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Returns the current spend edge for one wallet-owned outpoint. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +// considers outputs whose parent status is `pending` or `published`. +// - Returns the nullable `spent_by_tx_id` column so callers can distinguish +// between an external/dead parent and a wallet-owned conflict. +// +// Performance: +// - Targets one wallet-scoped outpoint through the unique `(tx_id, +// output_index)` key after the parent hash lookup. +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) { + row := q.queryRow(ctx, q.getUtxoSpendByOutpointStmt, GetUtxoSpendByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var spent_by_tx_id sql.NullInt64 + err := row.Scan(&spent_by_tx_id) + return spent_by_tx_id, err +} + +const InsertUtxo = `-- name: InsertUtxo :one +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + ?1 AS output_index, + ?2 AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = ?3 + AND t.wallet_id = ?4 + AND t.tx_status IN (0, 1) + AND + a.id = ?5 + AND ks.wallet_id = ?4 +RETURNING id +` + +type InsertUtxoParams struct { + OutputIndex int64 + Amount int64 + TxID int64 + WalletID int64 + AddressID int64 +} + +// Inserts a new UTXO row and returns its database ID. +// +// How: +// - Writes only the utxos table using already-resolved transaction and address +// IDs. +// - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +// the provided address ID belongs to the same wallet. +// +// Performance: +// - Single-row insert. The main cost is the wallet-ownership validation join +// plus FK and uniqueness checks. +func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) { + row := q.queryRow(ctx, q.insertUtxoStmt, InsertUtxo, + arg.OutputIndex, + arg.Amount, + arg.TxID, + arg.WalletID, + arg.AddressID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = ?2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = ?1 + ) +ORDER BY u.spent_by_tx_id +` + +type ListSpendingTxIDsByParentTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Lists direct child transaction IDs for one parent transaction ID. +// +// How: +// - Reads the spend edges already materialized on utxos through +// `(tx_id, spent_by_tx_id)`. +// - Returns only direct children; callers that need full descendant walks should +// traverse this query iteratively in application code. +// +// Performance: +// - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +// to one wallet-scoped parent. +func (q *Queries) ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) { + rows, err := q.query(ctx, q.listSpendingTxIDsByParentTxIDStmt, ListSpendingTxIDsByParentTxID, arg.WalletID, arg.TxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullInt64 + for rows.Next() { + var spent_by_tx_id sql.NullInt64 + if err := rows.Scan(&spent_by_tx_id); err != nil { + return nil, err + } + items = append(items, spent_by_tx_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUtxos = `-- name: ListUtxos :many +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + ?2 IS NULL + OR acc.account_number = ?2 + ) + AND ( + ?3 IS NULL + OR ?3 = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?3 + ) + AND ( + ?4 IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= ?4 + ) +ORDER BY u.amount, t.tx_hash, u.output_index +` + +type ListUtxosParams struct { + WalletID int64 + AccountNumber interface{} + MinConfirms interface{} + MaxConfirms interface{} +} + +type ListUtxosRow struct { + TxHash []byte + OutputIndex int64 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt64 +} + +// Lists unspent UTXOs that match the provided filters. +// +// How: +// - Starts from utxos and joins transactions for tx metadata plus +// wallet_sync_states for confirmation math. +// - Joins addresses to return the required script_pub_key, then reuses +// addresses -> accounts -> key_scopes so account filtering and wallet +// ownership checks happen in the same read. +// - Returns leased outputs too because the API models leases separately from +// UTXO existence. +// - Includes outputs whose parent transaction is still in a live unconfirmed +// state (`pending` or `published`). +// - Intentionally does not enforce coinbase maturity because this query models +// wallet-owned UTXO existence rather than a strictly spendable subset. +// +// Performance: +// - Restricts first by wallet, spend state, and transaction status. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +// - Treats min/max confirmations as optional filters so callers can +// distinguish "not set" from an explicit zero-conf request. +func (q *Queries) ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) { + rows, err := q.query(ctx, q.listUtxosStmt, ListUtxos, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUtxosRow + for rows.Next() { + var i ListUtxosRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const MarkUtxoSpent = `-- name: MarkUtxoSpent :execrows +UPDATE utxos +SET + spent_by_tx_id = ?4, + spent_input_index = ?5 +WHERE + utxos.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND t.tx_status IN (0, 1) + ) + AND utxos.output_index = ?3 + AND ( + (utxos.spent_by_tx_id IS NULL AND utxos.spent_input_index IS NULL) + OR (utxos.spent_by_tx_id = ?4 AND utxos.spent_input_index = ?5) + ) +` + +type MarkUtxoSpentParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt64 +} + +// Marks a wallet-owned UTXO as spent by a transaction. +// +// How: +// - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +// the statement so callers can update by network outpoint. +// - Requires the parent transaction status to be `pending` or `published` +// before a child spend edge can attach. +// - Only changes rows that are currently unspent or already point at the same +// `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +// without allowing a caller to silently rewrite which input spent the UTXO. +// +// Performance: +// - Targets one outpoint in one wallet; the subquery uses the unique +// wallet-scoped tx hash lookup. +func (q *Queries) MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) { + result, err := q.exec(ctx, q.markUtxoSpentStmt, MarkUtxoSpent, + arg.WalletID, + arg.TxHash, + arg.OutputIndex, + arg.SpentByTxID, + arg.SpentInputIndex, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} From 25869d589ed5c196e65deb1409c2af4234780038 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 24 Mar 2026 10:04:06 -0300 Subject: [PATCH 147/695] wallet: dynamic set pg itest store max connections --- wallet/internal/db/itest/pg_test.go | 61 +++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index d8975fa329..f376f3a012 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -5,9 +5,12 @@ package itest import ( "context" "database/sql" + "flag" "fmt" "os" "regexp" + "runtime" + "strconv" "strings" "sync" "testing" @@ -22,9 +25,6 @@ import ( ) var ( - // Limit concurrent database creation to avoid exhausting connections. - pgDBSemaphore = make(chan struct{}, 4) - // Shared container instance, reused across tests for performance. // This is safe to use concurrently because we only share the container // and not the database inside it. Each test gets its own database. @@ -45,6 +45,23 @@ var ( pgTerminateTimeout = 1 * time.Minute ) +// testParallelism returns the effective test parallelism level. It reads the +// -test.parallel flag when set, falling back to GOMAXPROCS (the default used by +// the Go test runner). +func testParallelism() int { + f := flag.Lookup("test.parallel") + if f == nil { + return runtime.GOMAXPROCS(0) + } + + n, err := strconv.Atoi(f.Value.String()) + if err != nil || n <= 0 { + return runtime.GOMAXPROCS(0) + } + + return n +} + // TestMain ensures the shared postgres container is terminated after the // integration test suite completes to avoid leaking docker resources. func TestMain(m *testing.M) { @@ -52,10 +69,16 @@ func TestMain(m *testing.M) { // Terminate the container after the test suite completes. if pgContainer != nil { - ctx, cancel := context.WithTimeout(context.Background(), pgTerminateTimeout) + ctx, cancel := context.WithTimeout( + context.Background(), pgTerminateTimeout, + ) defer cancel() - err := pgContainer.Terminate(ctx) + // As the tests already completed, we can stop the container + // immediately. + err := pgContainer.Terminate( + ctx, testcontainers.StopTimeout(0), + ) if err != nil { fmt.Printf("failed to terminate postgres container: %v\n", err) } @@ -91,9 +114,8 @@ func DefaultPostgresConfig() PostgresConfig { // GetPostgresContainer returns the shared PostgreSQL container instance. // The container is created once and reused across all tests for performance. -// -// Note: postgres:18-alpine defaults max_connections to 100. -func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, error) { +func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, + error) { pgContainerOnce.Do(func() { cfg := DefaultPostgresConfig() @@ -115,6 +137,12 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, err postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), + testcontainers.WithCmd( + "-c", fmt.Sprintf( + "max_connections=%d", + testParallelism()*db.DefaultMaxConnections, + ), + ), testcontainers.WithWaitStrategyAndDeadline( pgInitTimeout, waitForSQL, ), @@ -144,18 +172,19 @@ func sanitizedPgDBName(t *testing.T) string { } // NewTestStore creates a new PostgreSQL database connection with migrations -// applied. Each test gets its own database for isolation. +// applied. Each parallel subtest must call NewTestStore itself rather than +// sharing a store created by the parent test. When a parent test creates the +// store and its subtests call t.Parallel(), the parent finishes and releases +// its parallel slot while the subtests are still running. A new parent test +// fills that slot and opens another store, but the original subtests still +// hold their connections. This leads to more open connections than the parallel +// limit allows, exhausting the PostgreSQL connection pool. Avoid this by +// creating NewTestStore inside each parallel subtest so its lifecycle is tied +// to the subtest's parallel slot. func NewTestStore(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() - // Acquire a semaphore slot to limit concurrent database creation and - // parallel test execution that depends on it. - pgDBSemaphore <- struct{}{} - defer func() { - <-pgDBSemaphore - }() - container, err := GetPostgresContainer(ctx) require.NoError(t, err, "failed to get postgres container") From a88ecc96aa40a518e36ba5d0a6b40767eb9d2044 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 25 Mar 2026 18:37:17 -0300 Subject: [PATCH 148/695] wallet: give each parallel subtest its own test store When a parent test creates NewTestStore(t) and its subtests call t.Parallel(), the parent finishes and releases its parallel slot while subtests are still running. Another parent test fills that slot and opens its own store, but the original subtests still hold their store open. This leads to more open store connections than the parallel limit allows, exhausting PostgreSQL connections. Fix by creating NewTestStore(t) inside each parallel subtest so the store lifecycle is tied to the subtest's parallel slot. Table-driven error tests use plain struct params with WalletID injected after wallet creation. Setup funcs in TestGetAddress and TestListAddresses accept store and walletID parameters so each subtest operates on its own isolated database. --- .../internal/db/itest/account_store_test.go | 109 +++++++++--------- .../internal/db/itest/address_store_test.go | 95 +++++++++------ .../db/itest/address_types_store_test.go | 3 +- wallet/internal/db/itest/wallet_store_test.go | 2 +- 4 files changed, 119 insertions(+), 90 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 0f8be143c9..3a0e392a4a 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -51,10 +51,6 @@ func TestCreateAccounts(t *testing.T) { func TestCreateDerivedAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-create-derived-account-errors") - tests := []struct { name string params db.CreateDerivedAccountParams @@ -63,18 +59,16 @@ func TestCreateDerivedAccountErrors(t *testing.T) { { name: "missing name", params: db.CreateDerivedAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - Name: "", + Scope: db.KeyScopeBIP0084, + Name: "", }, wantErr: db.ErrMissingAccountName, }, { name: "unknown scope", params: db.CreateDerivedAccountParams{ - WalletID: walletID, - Scope: db.KeyScope{Purpose: 999, Coin: 999}, - Name: "unknown-scope-account", + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + Name: "unknown-scope-account", }, wantErr: db.ErrUnknownKeyScope, }, @@ -84,6 +78,10 @@ func TestCreateDerivedAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + info, err := store.CreateDerivedAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, info) @@ -222,10 +220,6 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { func TestCreateImportedAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-create-imported-account-errors") - tests := []struct { name string params db.CreateImportedAccountParams @@ -234,7 +228,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "missing name", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "", Scope: db.KeyScopeBIP0084, EncryptedPublicKey: RandomBytes(32), @@ -244,7 +237,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "missing public key", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "missing-pubkey", Scope: db.KeyScopeBIP0084, EncryptedPublicKey: nil, @@ -254,7 +246,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "unknown scope", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "unknown-scope", Scope: db.KeyScope{Purpose: 999, Coin: 999}, EncryptedPublicKey: RandomBytes(32), @@ -267,6 +258,10 @@ func TestCreateImportedAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + props, err := store.CreateImportedAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, props) @@ -345,17 +340,15 @@ func TestGetAccount(t *testing.T) { func TestGetAccountNotFound(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-get-account-not-found") - - createAllAccounts(t, store, walletID) - scope := db.KeyScopeBIP0084 t.Run("by name", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-account-not-found-name") + createAllAccounts(t, store, walletID) + query := getAccountQueryByName(walletID, scope, "non-existent") info, err := store.GetAccount(t.Context(), query) require.ErrorIs(t, err, db.ErrAccountNotFound) @@ -365,6 +358,10 @@ func TestGetAccountNotFound(t *testing.T) { t.Run("by number", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-account-not-found-number") + createAllAccounts(t, store, walletID) + query := getAccountQueryByNumber(walletID, scope, 99999) info, err := store.GetAccount(t.Context(), query) require.ErrorIs(t, err, db.ErrAccountNotFound) @@ -380,15 +377,13 @@ func TestListAccounts(t *testing.T) { // Ensure that has at least 3 accounts to be tested. require.GreaterOrEqual(t, len(AllAccountCases), 3) - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-list-accounts") - - createAllAccounts(t, store, walletID) - t.Run("all accounts", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-all") + createAllAccounts(t, store, walletID) + query := db.ListAccountsQuery{WalletID: walletID} accounts, err := store.ListAccounts(t.Context(), query) require.NoError(t, err) @@ -402,6 +397,10 @@ func TestListAccounts(t *testing.T) { t.Run("filter by scope", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-scope") + createAllAccounts(t, store, walletID) + scope := db.KeyScopeBIP0084 query := db.ListAccountsQuery{ WalletID: walletID, @@ -422,6 +421,10 @@ func TestListAccounts(t *testing.T) { t.Run("filter by name", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-name") + createAllAccounts(t, store, walletID) + // Ensure that has at least 3 derived accounts to be tested. require.GreaterOrEqual(t, len(DerivedAccountCases), 3) @@ -440,6 +443,8 @@ func TestListAccounts(t *testing.T) { t.Run("empty result", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + // Create a new wallet with no accounts. emptyWalletID := newWallet(t, store, "wallet-list-empty") query := db.ListAccountsQuery{WalletID: emptyWalletID} @@ -605,15 +610,6 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-rename-account-errors") - - createAllAccounts(t, store, walletID) - - nonExistentName := "nonexistent" - nonExistentNum := uint32(99999) - tests := []struct { name string params db.RenameAccountParams @@ -622,40 +618,35 @@ func TestRenameAccountErrors(t *testing.T) { { name: "not found", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "new-name", }, wantErr: db.ErrAccountNotFound, }, { name: "invalid - both set", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - AccountNumber: &nonExistentNum, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, { name: "invalid - neither set", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, { name: "invalid - empty new name", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - NewName: "", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "", }, wantErr: db.ErrMissingAccountName, }, @@ -665,6 +656,16 @@ func TestRenameAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rename-account-errors") + createAllAccounts(t, store, walletID) + tc.params.WalletID = walletID + + if tc.name == "invalid - both set" { + num := uint32(99999) + tc.params.AccountNumber = &num + } + err := store.RenameAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) }) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 87f6e11745..c62f73751d 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -572,20 +572,21 @@ func TestGetAddressSecret(t *testing.T) { func TestGetAddress(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-get-address") - tests := []struct { name string - setupFunc func(t *testing.T) db.GetAddressQuery - wantErr error - validate func(t *testing.T, addr *db.AddressInfo) + setupFunc func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, walletID uint32) db.GetAddressQuery + wantErr error + validate func(t *testing.T, addr *db.AddressInfo) }{ { name: "get by encrypted script pubkey", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.GetAddressQuery { + createImportedAccount( - t, store, walletID, db.KeyScopeBIP0084, "imported", + t, accountStore, walletID, db.KeyScopeBIP0084, "imported", ) script := RandomBytes(32) @@ -596,7 +597,7 @@ func TestGetAddress(t *testing.T) { PubKey: RandomBytes(33), ScriptPubKey: script, } - _, err := store.NewImportedAddress(t.Context(), params) + _, err := addrStore.NewImportedAddress(t.Context(), params) require.NoError(t, err) return db.GetAddressQuery{ @@ -611,7 +612,9 @@ func TestGetAddress(t *testing.T) { }, { name: "address not found by script", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(_ *testing.T, _ db.AddressStore, + _ db.AccountStore, walletID uint32) db.GetAddressQuery { + return db.GetAddressQuery{ WalletID: walletID, ScriptPubKey: RandomBytes(32), @@ -621,7 +624,9 @@ func TestGetAddress(t *testing.T) { }, { name: "invalid query - empty script pubkey", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(_ *testing.T, _ db.AddressStore, + _ db.AccountStore, walletID uint32) db.GetAddressQuery { + return db.GetAddressQuery{ WalletID: walletID, ScriptPubKey: nil, @@ -634,7 +639,11 @@ func TestGetAddress(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - query := tc.setupFunc(t) + + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + + query := tc.setupFunc(t, store, store, walletID) addr, err := store.GetAddress(t.Context(), query) if tc.wantErr != nil { @@ -658,25 +667,28 @@ func TestGetAddress(t *testing.T) { func TestListAddresses(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-addresses") - tests := []struct { name string - setupFunc func(t *testing.T) db.ListAddressesQuery + setupFunc func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, walletID uint32) db.ListAddressesQuery wantCount int wantErr error validate func(t *testing.T, addrs []db.AddressInfo) }{ { name: "list multiple addresses for account", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0044, "test-account", + t, accountStore, walletID, db.KeyScopeBIP0044, + "test-account", ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0044, "test-account", + t, addrStore, walletID, db.KeyScopeBIP0044, + "test-account", false, 5, ) @@ -698,9 +710,13 @@ func TestListAddresses(t *testing.T) { }, { name: "list addresses - empty result", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, _ db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0084, "empty-account", + t, accountStore, walletID, db.KeyScopeBIP0084, + "empty-account", ) return db.ListAddressesQuery{ @@ -713,22 +729,29 @@ func TestListAddresses(t *testing.T) { }, { name: "list addresses filters by scope correctly", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + // Create accounts in different scopes. createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + t, accountStore, walletID, db.KeyScopeBIP0044, + "bip44-multi", ) createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + t, accountStore, walletID, db.KeyScopeBIP0049Plus, + "bip49-multi", ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + t, addrStore, walletID, db.KeyScopeBIP0044, + "bip44-multi", false, 3, ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + t, addrStore, walletID, db.KeyScopeBIP0049Plus, + "bip49-multi", false, 2, ) @@ -752,7 +775,11 @@ func TestListAddresses(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - query := tc.setupFunc(t) + + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + + query := tc.setupFunc(t, store, store, walletID) addrs, err := store.ListAddresses(t.Context(), query) if tc.wantErr != nil { @@ -966,9 +993,6 @@ func TestListAddressesOrdering(t *testing.T) { func TestNewDerivedAddressErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-new-derived-address-errors") - tests := []struct { name string params db.NewDerivedAddressParams @@ -977,7 +1001,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "non-existent account", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "non-existent", Change: false, @@ -987,7 +1010,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "empty account name", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "", Change: false, @@ -997,7 +1019,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "unknown key scope returns account not found", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScope{ Purpose: 999, Coin: 999, @@ -1013,7 +1034,13 @@ func TestNewDerivedAddressErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - info, err := store.NewDerivedAddress(t.Context(), tc.params, mockDeriveFunc()) + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + + info, err := store.NewDerivedAddress( + t.Context(), tc.params, mockDeriveFunc(), + ) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, info) }) diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index fec40e59e6..d8fb45d9ab 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -33,7 +33,6 @@ func TestListAddressTypes(t *testing.T) { func TestGetAddressType(t *testing.T) { t.Parallel() - store := NewTestStore(t) tests := []struct { id db.AddressType @@ -119,6 +118,8 @@ func TestGetAddressType(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + got, err := store.GetAddressType(t.Context(), tc.id) if tc.wantErr != nil { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index fcc098d9b1..8f7f5c3624 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -100,8 +100,8 @@ func TestCreateWallet_Variants(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store := NewTestStore(t) params := tc.params(tc.name) + store := NewTestStore(t) info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) From 86846eb1bc9cf88920bea77673fc74bf77bb729e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 25 Mar 2026 20:33:27 +0800 Subject: [PATCH 149/695] bwtest: wait for miner p2p readiness bitcoind can race the rpctest miner listener during harness startup and stick at height 0 until a later reconnect, which shows up as a flaky bitcoind v30 itest failure in CI. Poll the miner P2P address before starting external backends so the first outbound peer attempt has a listener to connect to. --- bwtest/harness.go | 2 ++ bwtest/tcp.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 bwtest/tcp.go diff --git a/bwtest/harness.go b/bwtest/harness.go index 13ca4e0203..7114cee090 100644 --- a/bwtest/harness.go +++ b/bwtest/harness.go @@ -70,6 +70,8 @@ func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { minerLogDir := createOrEnsureLogSubDir(t, logDir, "miner") miner := newMiner(t, minerLogDir) miner.SetUp() + require.NoError(t, waitForTCPListener(miner.P2PAddress(), + defaultTestTimeout), "miner p2p listener not ready") // 2. Start Chain Backend. backendLogDir := "" diff --git a/bwtest/tcp.go b/bwtest/tcp.go new file mode 100644 index 0000000000..76d534e6be --- /dev/null +++ b/bwtest/tcp.go @@ -0,0 +1,41 @@ +package bwtest + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/btcsuite/btcwallet/bwtest/wait" +) + +// waitForTCPListener polls until addr accepts TCP connections. +// +// The integration harness uses this before starting external backends that +// immediately dial the miner. Without the extra readiness check, a backend's +// first outbound peer attempt can race the rpctest miner listener on slower CI +// runners and leave the backend stuck at height 0 until its next reconnect. +func waitForTCPListener(addr string, timeout time.Duration) error { + err := wait.NoError(func() error { + ctx, cancel := context.WithTimeout( + context.Background(), wait.PollInterval, + ) + defer cancel() + + dialer := &net.Dialer{} + + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + _ = conn.Close() + + return nil + }, timeout) + if err != nil { + return fmt.Errorf("wait for tcp listener %s: %w", addr, err) + } + + return nil +} From 5fa9021146c5dfbb8f2885945efc9726c28eb9d4 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 27 Mar 2026 11:14:45 -0300 Subject: [PATCH 150/695] wallet: refactor itest testing his name --- wallet/internal/db/itest/account_store_test.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 3a0e392a4a..5d8c8ecafa 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -610,6 +610,9 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() + accountNumber := uint32(99999) + accountPointer := &accountNumber + tests := []struct { name string params db.RenameAccountParams @@ -627,9 +630,10 @@ func TestRenameAccountErrors(t *testing.T) { { name: "invalid - both set", params: db.RenameAccountParams{ - Scope: db.KeyScopeBIP0084, - OldName: "nonexistent", - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + AccountNumber: accountPointer, + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, @@ -661,11 +665,6 @@ func TestRenameAccountErrors(t *testing.T) { createAllAccounts(t, store, walletID) tc.params.WalletID = walletID - if tc.name == "invalid - both set" { - num := uint32(99999) - tc.params.AccountNumber = &num - } - err := store.RenameAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) }) From a4981e1161a10a28a40c4194d5a3c2f814c71730 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 27 Mar 2026 13:55:32 -0300 Subject: [PATCH 151/695] wallet: size postgres itest max_connections to P*M + M + 3*P --- wallet/internal/db/itest/pg_test.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f376f3a012..723e481e7b 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -132,16 +132,33 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, }, ).WithStartupTimeout(pgInitTimeout) + p := testParallelism() + m := db.DefaultMaxConnections + + // pgMaxConns is the Postgres max_connections budget for the + // test container. It is sized as P*M + M + 3*P where: + // + // P*M — steady-state: up to P parallel tests each holding a + // pool of at most M connections (db.SetMaxOpenConns). + // + // +M — teardown latency: one extra store-equivalent for a + // store that has called Close() but whose connections + // have not yet fully disappeared from Postgres. + // + // +3*P — per-slot bootstrap overlap: each slot needs roughly + // 3 transient connections while a new test starts — + // one admin connection for CREATE DATABASE, ~1 for + // PingContext, and ~1 for migration setup — so 3*P + // covers all slots transitioning simultaneously. + pgMaxConns := p*m + m + 3*p + pgContainer, pgContainerErr = postgres.Run(ctx, cfg.Image, postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), testcontainers.WithCmd( - "-c", fmt.Sprintf( - "max_connections=%d", - testParallelism()*db.DefaultMaxConnections, - ), + "-c", fmt.Sprintf("max_connections=%d", pgMaxConns), ), testcontainers.WithWaitStrategyAndDeadline( pgInitTimeout, waitForSQL, From c94a49d10c4be4559e8c9757886420bd50f9b6b7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Feb 2026 09:19:28 +0800 Subject: [PATCH 152/695] chain: fix flaky TestPrunedBlockDispatcherQuerySameBlock The test's stop() method asserted that the queriedPeer channel was empty after shutdown. However, the neutrino work manager can non-deterministically dispatch the same query to multiple peers via retries or worker redistribution, producing extra OnGetData callbacks. This caused sporadic "did not consume all queriedPeer signals" failures. Replace the strict empty-check with a drain loop. Tests that need to verify specific query counts already do so explicitly via assertPeerQueried. --- chain/pruned_block_dispatcher_test.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/chain/pruned_block_dispatcher_test.go b/chain/pruned_block_dispatcher_test.go index d6782d3510..24aea12330 100644 --- a/chain/pruned_block_dispatcher_test.go +++ b/chain/pruned_block_dispatcher_test.go @@ -181,10 +181,18 @@ func (h *prunedBlockDispatcherHarness) stop() { default: } - select { - case <-h.queriedPeer: - h.t.Fatal("did not consume all queriedPeer signals") - default: + // Drain any remaining queriedPeer signals. The exact number of peer + // queries depends on the work manager's internal scheduling (e.g. + // retries, worker redistribution) and is non-deterministic. Tests + // that care about specific query counts already assert them + // explicitly via assertPeerQueried. +drainQueriedPeer: + for { + select { + case <-h.queriedPeer: + default: + break drainQueriedPeer + } } require.Empty(h.t, h.blocksQueried) @@ -271,11 +279,6 @@ func (h *prunedBlockDispatcherHarness) query(blocks []*chainhash.Hash, cancelChan := make(chan error, 1) blockChan, errChan := h.dispatcher.Query(blocks, cancelChan, opts...) - select { - case err := <-errChan: - require.NoError(h.t, err) - default: - } for _, block := range blocks { h.blocksQueried[*block]++ From 405a9a84f79f0a85dbd76ff67aba05921e9af9d2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 18:07:06 +0800 Subject: [PATCH 153/695] wallet: double postgres itest max_connections budget Keep the parallel coverage itests from exhausting Postgres connections without relying on a tighter transient overlap estimate. --- wallet/internal/db/itest/pg_test.go | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 723e481e7b..c400b5855a 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -136,21 +136,11 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, m := db.DefaultMaxConnections // pgMaxConns is the Postgres max_connections budget for the - // test container. It is sized as P*M + M + 3*P where: - // - // P*M — steady-state: up to P parallel tests each holding a - // pool of at most M connections (db.SetMaxOpenConns). - // - // +M — teardown latency: one extra store-equivalent for a - // store that has called Close() but whose connections - // have not yet fully disappeared from Postgres. - // - // +3*P — per-slot bootstrap overlap: each slot needs roughly - // 3 transient connections while a new test starts — - // one admin connection for CREATE DATABASE, ~1 for - // PingContext, and ~1 for migration setup — so 3*P - // covers all slots transitioning simultaneously. - pgMaxConns := p*m + m + 3*p + // test container. We budget 2x the steady-state pool size to + // absorb connection lifecycle overlap during parallel test + // teardown and startup without trying to model each transient + // connection source separately. + pgMaxConns := 2 * p * m pgContainer, pgContainerErr = postgres.Run(ctx, cfg.Image, From a04ea58bf03e513dbf569042817db403d09c2790 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 19:27:37 +0800 Subject: [PATCH 154/695] docs: add repo guide for coding agents Document btcwallet's build, test, and style conventions so coding agents can work consistently in this repository. --- AGENTS.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1fdf90145f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +This file is for coding agents working in `btcwallet`. + +## Scope and Priority + +- Follow this file first for repo-specific workflow and style. +- Then follow `docs/developer/` for deeper rationale. +- If guidance conflicts, prefer the stricter rule and match nearby code. + +## Repo Workflow + +- Keep the main checkout at the repo root. +- Place auxiliary worktrees under `/.worktrees/`. +- Use short issue- or task-oriented worktree names. +- Do not create sibling worktrees outside the repo root unless asked. +- Remove finished worktrees with `git worktree remove `. +- Clean stale worktree metadata with `git worktree prune`. + +## Tooling and Environment + +- The authoritative Go version is `1.24.6`. +- Sources: `go.mod`, `.golangci.yml`, and `.github/workflows/main.yml`. +- Some older docs mention older Go versions; ignore them. +- Many maintenance targets use Docker-backed tooling. +- `make fmt`, `make lint*`, `make sqlc`, and `make protolint` use Docker. +- PostgreSQL DB integration tests also require Docker via testcontainers. +- No Cursor rules were found in `.cursor/rules/` or `.cursorrules`. +- No Copilot instructions were found in `.github/copilot-instructions.md`. +- There is an additional style summary in `.gemini/styleguide.md`. + +## Primary Build, Format, and Codegen Commands + +- Build everything: `make build` +- Install binaries: `make install` +- CI compile check: `go install -v ./...` +- Default `make` target is `make build`. +- `make install` installs `btcwallet`, `cmd/dropwtxmgr`, and `cmd/sweepaccount`. +- Go format/imports: `make fmt`; verify with `make fmt-check` +- Lint: `make lint-config-check`, `make lint-check`, `make lint`; optional `workers=4` +- Proto: `make rpc-format`, `make rpc`, `make rpc-check`, `make protolint` +- SQL: `make sql-parse`, `make sql-format`, `make sql-format-check`, `make sql-lint`, `make sql-lint-check`, `make sqlc`, `make sqlc-check`, `make sql` +- Modules and config: `make tidy-module`, `make tidy-module-check`, `make sample-conf-check` + +## Command Gotchas + +- Several `*-check` targets modify files before checking git cleanliness. +- `make fmt-check` runs `make fmt` first. +- `make rpc-check` runs code generation first. +- `make sqlc-check` runs SQL codegen first. +- `make sql-format-check` formats SQL first. +- `make tidy-module-check` runs `go mod tidy` first. +- Do not run those blindly in a dirty tree unless you expect edits. + +## Unit Test Commands + +- Run all unit tests: `make unit` +- Run one package: `make unit pkg=wallet` +- Run one specific test: `make unit pkg=wallet case=TestBuildTxDetail` +- Common flags: `verbose=1`, `nocache=1`, `timeout=5m` +- Run unit tests with race detector: `make unit-race` +- Run targeted race test: `make unit-race pkg=wallet case=TestBuildTxDetail` +- Run unit coverage: `make unit-cover` +- Run targeted coverage: `make unit-cover pkg=wallet` +- Run benchmarks for one package: `make unit-bench pkg=wallet` +- Include alloc stats: `make unit-bench pkg=wallet benchmem=1` + +## Integration Test Commands + +- DB itests: `make itest-db db=sqlite` or `make itest-db db=postgres` +- Single DB itest: `make itest-db db=postgres case=TestWhatever verbose=1` +- DB coverage/race: `make itest-db db=postgres cover=1 verbose=1`, `make itest-db-race db=sqlite verbose=1` +- The DB integration suite lives in `wallet/internal/db/itest`. +- E2E default: `make itest` +- E2E backends: `make itest chain=btcd db=kvdb`, `make itest chain=neutrino db=kvdb`, `make itest chain=bitcoind db=kvdb` +- E2E case filter: `make itest icase=manager` or `make itest chain=btcd db=kvdb icase=manager` +- E2E logs go to `itest/test-logs/`; case names must follow `component action` and must not use `_`. + +## Verification Strategy + +- Run the narrowest relevant test first. +- Before handing off a substantial change, run at least package-level tests. +- For SQL, proto, module, or config changes, run the matching generation/check target. +- For DB-layer changes, prefer `itest-db` in addition to unit tests. +- For backend flow or RPC changes, consider `make itest` coverage. +- If a change claims performance improvement, add or run a benchmark. +- CI covers formatting, imports, modules, proto, SQL, lint, unit, DB, and e2e. + +## Go Formatting and Imports + +- Follow `Effective Go` and the repo docs in `docs/developer/`. +- Let `make fmt` manage imports through `gosimports` and `gofmt`. +- Do not manually fight import grouping; accept formatter output. +- Go files use tab indentation. +- Markdown files use LF and wrap to 80 characters. +- Keep lines near 80 columns on a best-effort basis. +- The style docs mention treating tabs as width 8 for visual wrapping. +- `.editorconfig` and linter settings use width 4; keep lines conservative. +- Formatting excludes generated `*.pb.go` files. + +## Code Layout and Naming + +- Break functions into logical stanzas separated by blank lines. +- Add comments where intent is not obvious; explain why, not the mechanics. +- Every function should have a purpose comment; comments must start with the function name. +- Exported functions need caller-oriented comments, not just maintainer notes. +- Wrap long function calls one argument per line with `)` on its own line. +- If a function declaration spans multiple lines, start the body after a blank line. +- Avoid generic package names like `utils`, `common`, or `helpers`; use `internal` for non-public code. +- Prefer domain-focused package boundaries, avoid circular dependencies, and accept interfaces while returning concrete structs. +- Match existing names in the surrounding package before inventing new terms. + +## Types, Errors, and Concurrency + +- Put `context.Context` first for blocking or long-running operations. +- Wrap dependency errors with context using `%w`. +- Prefer sentinel errors for important conditions and check with `errors.Is`. +- Define normal non-exceptional cases out of the error path when practical. +- Prefer communicating over shared memory. +- Never start a goroutine without a clear shutdown path. +- Do not access maps concurrently without synchronization. +- Treat slices as shared mutable state unless ownership is explicit. +- Pass sync primitives by pointer, not by value. + +## Logging Guidelines + +- Supported levels are `trace`, `debug`, `info`, `warn`, `error`, `critical`. +- Use `error` for unexpected internal failures. +- Expected external failures usually belong at `info`, `debug`, or `warn`. +- Much of the repo still uses legacy `log.Tracef/Debugf/...` patterns. +- In files that already use legacy logging, preserve the local style. +- If adding structured logging, keep the message static and put data in attributes. +- Use `slog.Attr` or helpers like `btclog.Fmt` for structured log fields. +- Log and error formatting are exceptions to the usual multiline call wrapping. + +## Test Style Guidelines + +- New non-trivial behavior and bug fixes should come with regression tests. +- Cover both positive paths and negative or error paths. +- Prefer `require` over `assert` for most checks. +- Structure tests as Arrange, Act, Assert with blank lines between sections. +- Use table-driven tests only when setup shape is identical across cases. +- If setup differs across cases, prefer separate standalone tests and keep case structs data-only. +- Use descriptive flat test names and `t.Parallel()` where safe. +- Tests commonly use fast scrypt parameters; do not remove that optimization. + +## SQL, Proto, Modules, and PRs + +- SQL is formatted and linted with SQLFluff. +- SQL keywords and types should be uppercase; identifiers and function names should be lowercase. +- Prefer `!=` over `<>`; SQL indentation uses 4 spaces. +- Protos are formatted with `clang-format` through `make rpc-format`. +- Proto messages use UpperCamelCase; filenames use lower_snake_case. +- Proto imports should be sorted, package names lowercase, and services/RPCs commented. +- The repo contains multiple Go submodules; avoid ad hoc local `replace` directives. +- Favor small, reviewable commits. +- Commit subjects typically look like `subsystem: short description`. +- Use present tense, keep the subject near 50 chars, and wrap bodies near 72. +- PRs should include clear test steps and cover positive and negative cases. +- Insubstantial typo-only changes are discouraged by the contribution guide. From e26a99ab06c653fa84a419fceade664920f21e3c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 19:27:37 +0800 Subject: [PATCH 155/695] repo: ignore repo-local worktrees Ignore the repo-local .worktrees directory so auxiliary worktree checkouts do not pollute git status. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 218d27859d..1ab06d6ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage-itest-sqlite.txt .vscode .DS_Store .aider* +/.worktrees/ coverage.out *.prof *.test From 7de78bce458b3f8d8fff195774b6b2e4e19a1e90 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:48:15 +0800 Subject: [PATCH 156/695] wallet: update store interfaces's terminologies Clarify the published-status wording, explain the dedicated blockless ListTxns path, and make DeleteTx state the exact unmined statuses it accepts. These comments make the tx store contract easier to read before the method implementations land. --- wallet/internal/db/data_types.go | 17 ++++++++++++----- wallet/internal/db/interface.go | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 2bb588add9..96f294f580 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -743,7 +743,7 @@ const ( // caller whether the transaction is mined. Keeping both under // TxStatusPublished avoids contradictory combinations such as // "confirmed but not published" and keeps this field focused on whether the - // wallet still treats the transaction as live. + // wallet still treats the transaction as valid. TxStatusPublished // TxStatusReplaced indicates a transaction that was invalidated by a @@ -945,9 +945,16 @@ type ListTxnsQuery struct { // EndHeight is the ending block height for the query. EndHeight uint32 - // UnminedOnly, if true, will return only unmined (unconfirmed) - // transactions. If this is set, StartHeight and EndHeight will be - // ignored. + // UnminedOnly, if true, switches ListTxns onto the dedicated no-confirming- + // block read path. + // + // This path returns the active unmined set together with retained invalid + // history rows that also no longer have a confirming block, such as + // orphaned or failed transactions after rollback. + // + // This is not equivalent to using zero confirmations. The confirmed + // height-range query cannot express "only rows with no block", so + // StartHeight and EndHeight are ignored when this flag is set. UnminedOnly bool } @@ -1071,7 +1078,7 @@ type LeasedOutput struct { // BalanceResult represents one wallet-scoped balance view after applying the // requested filters. type BalanceResult struct { - // Total is the sum of every matching live UTXO, including leased outputs. + // Total is the sum of every matching UTXO, including leased outputs. Total btcutil.Amount // Locked is the subset of Total currently covered by active output leases. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index d83a0afb54..506d21421a 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -283,8 +283,8 @@ type TxStore interface { // returning a slice of TxInfo or an error if the retrieval fails. ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) - // DeleteTx removes a live unconfirmed transaction from the store. It - // takes a context and DeleteTxParams, returning an error if the + // DeleteTx removes an unmined pending or published transaction from the + // store. It takes a context and DeleteTxParams, returning an error if the // transaction is not found or the deletion fails. // // DeleteTx is intentionally narrower than a generic @@ -319,16 +319,16 @@ type UTXOStore interface { // outpoint. It returns a UtxoInfo struct containing the UTXO's details // or an error if the UTXO is not found or has been spent. // - // GetUtxo treats outputs created by both live unconfirmed states - // (TxStatusPending and TxStatusPublished) as present in the wallet's - // UTXO set. + // GetUtxo treats outputs created by unmined `pending` and `published` + // transactions as present in the wallet's UTXO set. GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) // that match the provided query parameters. This can be used to list // all UTXOs or filter them by account or confirmation status. // - // ListUTXOs includes outputs from live unconfirmed parent transactions. + // ListUTXOs includes outputs from unmined `pending` and `published` + // parent transactions. // Spendability policy such as coinbase maturity, lease state, or whether // a caller wants to exclude TxStatusPending parents belongs in higher-level // selection logic. @@ -350,7 +350,8 @@ type UTXOStore interface { // ErrOutputUnlockNotAllowed. ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error - // ListLeasedOutputs returns a slice of all currently leased live UTXOs. + // ListLeasedOutputs returns a slice of all currently leased UTXOs whose + // parent transaction is still `pending` or `published`. // This can be used to inspect which still-unspent outputs are currently // locked and when their leases expire. ListLeasedOutputs(ctx context.Context, walletID uint32) ( @@ -359,7 +360,8 @@ type UTXOStore interface { // Balance returns a wallet-scoped balance view for the current unspent UTXO // set after applying any optional caller-supplied filters. // - // The zero-value BalanceParams request the wallet's live factual balance. + // The zero-value BalanceParams request the wallet's current factual + // balance. // Callers may narrow that view by account, confirmation range, lease // or coinbase maturity when they need a workflow-specific balance policy // from the public store interface. The returned BalanceResult always uses From c14ed90e4cafc38bbf7673694e78b9ee3058f3a5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:51:09 +0800 Subject: [PATCH 157/695] wallet: update store SQL's terminologies Clarify the transaction-state terminology in the handwritten SQL and migration comments so unmined, invalid, and rollback paths are described consistently. Keep the matching sqlc output in the same commit so the generated query docs stay aligned with the handwritten SQL. --- .../postgres/000007_transactions.up.sql | 10 ++--- .../sqlite/000007_transactions.up.sql | 12 +++--- .../db/queries/postgres/transactions.sql | 22 +++++----- .../db/queries/postgres/utxo_leases.sql | 4 +- wallet/internal/db/queries/postgres/utxos.sql | 15 +++---- .../db/queries/sqlite/transactions.sql | 19 +++++---- .../db/queries/sqlite/utxo_leases.sql | 4 +- wallet/internal/db/queries/sqlite/utxos.sql | 15 +++---- wallet/internal/db/sqlc/postgres/querier.go | 41 ++++++++++--------- .../db/sqlc/postgres/transactions.sql.go | 22 +++++----- .../db/sqlc/postgres/utxo_leases.sql.go | 4 +- wallet/internal/db/sqlc/postgres/utxos.sql.go | 15 +++---- wallet/internal/db/sqlc/sqlite/querier.go | 38 +++++++++-------- .../db/sqlc/sqlite/transactions.sql.go | 19 +++++---- .../db/sqlc/sqlite/utxo_leases.sql.go | 4 +- wallet/internal/db/sqlc/sqlite/utxos.sql.go | 15 +++---- 16 files changed, 134 insertions(+), 125 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql index c7bd9b5f16..c2c3f54348 100644 --- a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql +++ b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql @@ -83,7 +83,7 @@ CREATE TABLE transactions ( -- A transaction attached to a block is treated as confirmed wallet history. -- For confirmed rows, the only valid status is `published`; every other - -- status represents either blockless local state or disconnected history. + -- status represents either unmined local state or disconnected history. CONSTRAINT check_confirmed_published CHECK ( block_height IS NULL OR tx_status = 1 ), @@ -103,19 +103,19 @@ CREATE TABLE transactions ( ) ); --- Optimization for live unconfirmed transaction lookups. +-- Optimization for unmined pending/published transaction lookups. CREATE INDEX idx_transactions_unconfirmed ON transactions (wallet_id, block_height) WHERE block_height IS NULL AND tx_status IN (0, 1); --- Optimization for wallet-scoped joins into the live transaction set. +-- Optimization for wallet-scoped joins into pending/published transactions. CREATE INDEX idx_transactions_live_by_wallet ON transactions (wallet_id, id) WHERE tx_status IN (0, 1); --- Optimization for wallet-scoped blockless history reads ordered by newest +-- Optimization for wallet-scoped unmined history reads ordered by newest -- receive time first. -CREATE INDEX idx_transactions_blockless_history +CREATE INDEX idx_transactions_unmined_history ON transactions (wallet_id, received_time DESC, id DESC) WHERE block_height IS NULL; diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql index e6c9194b82..85b8f06783 100644 --- a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql @@ -80,7 +80,7 @@ CREATE TABLE transactions ( -- A transaction attached to a block is treated as confirmed wallet history. -- For confirmed rows, the only valid status is `published`; every other - -- status represents either blockless local state or disconnected history. + -- status represents either unmined local state or disconnected history. CONSTRAINT check_confirmed_published CHECK ( block_height IS NULL OR tx_status = 1 ), @@ -100,19 +100,19 @@ CREATE TABLE transactions ( ) ); --- Optimization for live unconfirmed transaction lookups. +-- Optimization for unmined pending/published transaction lookups. CREATE INDEX idx_transactions_unconfirmed ON transactions (wallet_id, block_height) WHERE block_height IS NULL AND tx_status IN (0, 1); --- Optimization for wallet-scoped joins into the live transaction set. +-- Optimization for wallet-scoped joins into pending/published transactions. CREATE INDEX idx_transactions_live_by_wallet ON transactions (wallet_id, id) WHERE tx_status IN (0, 1); --- Optimization for wallet-scoped blockless history reads ordered by newest +-- Optimization for wallet-scoped unmined history reads ordered by newest -- receive time first. -CREATE INDEX idx_transactions_blockless_history +CREATE INDEX idx_transactions_unmined_history ON transactions (wallet_id, received_time DESC, id DESC) WHERE block_height IS NULL; @@ -161,7 +161,7 @@ BEGIN -- "coinbase + NULL block + published" combination. tx_status = CASE WHEN is_coinbase THEN 4 - -- Ordinary transactions just become blockless; their non-orphaned + -- Ordinary transactions just become unmined; their non-orphaned -- status is preserved for later rollback/invalidation handling. ELSE tx_status END, diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 0f41958652..7b09412ab1 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -67,18 +67,18 @@ LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = $1 AND t.tx_hash = $2; -- name: ListUnminedTransactions :many --- Lists all unconfirmed transactions for a wallet. +-- Lists the wallet transactions that still belong to the active unmined set. -- -- How: --- - Reads from transactions only and filters on blockless rows that are still --- in a live unconfirmed state (`pending` or `published`). --- - Excludes orphaned/replaced/failed history so rollback-produced coinbase --- rows do not reappear as mempool transactions. --- - Returns typed NULL block metadata explicitly because live unmined rows have --- no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` --- keep the unmined row shape aligned with the confirmed query below. +-- - Reads from transactions only and filters on unmined rows that are still +-- in unmined `pending` or `published` status. +-- - Excludes orphaned/replaced/failed history so delete and rollback logic do +-- not treat retained invalid rows as active mempool spends. +-- - Returns typed NULL block metadata explicitly because unmined rows have no +-- block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the row shape aligned with other transaction queries. -- Performance: --- - Matches the dedicated blockless-history index while the more selective +-- - Matches the dedicated unmined-history index while the more selective -- live-only partial index stays available for conflict paths. SELECT t.id, @@ -184,7 +184,7 @@ WHERE -- -- How: -- - Deletes only rows whose `block_height` is still NULL and whose status is --- still in a live unconfirmed state (`pending` or `published`). +-- still unmined `pending` or `published`. -- - Preserves orphaned/replaced/failed history; those rows must remain visible -- for audit/reorg handling instead of being treated as ordinary mempool data. -- - The caller must delete or restore dependent UTXO rows first. @@ -204,7 +204,7 @@ WHERE -- How: -- - Reads only confirmed coinbase rows at or above the rollback boundary. -- - Returns wallet scope alongside each tx hash so callers can treat these --- coinbase transactions as rollback roots when invalidating now-dead +-- coinbase transactions as rollback roots when invalidating now-invalid -- descendants inside the same rollback transaction. -- - This is a rollback-specific helper, not a generic "coinbase txs from one -- block" listing query. diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/db/queries/postgres/utxo_leases.sql index 262a2287ea..d5d7d4fd0b 100644 --- a/wallet/internal/db/queries/postgres/utxo_leases.sql +++ b/wallet/internal/db/queries/postgres/utxo_leases.sql @@ -6,7 +6,7 @@ -- - Resolves the outpoint to a current UTXO row and writes the lease in the -- same statement. -- - Rechecks that the outpoint is still unspent and its parent transaction is --- still in a live state (`pending` or `published`) at write time. +-- still in `pending` or `published` status at write time. -- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, -- and expired-lease takeover all happen atomically. -- Lease semantics: @@ -90,7 +90,7 @@ WHERE -- can be returned as network outpoints. -- - Filters out expired rows using the caller-supplied UTC timestamp. -- - Restricts the result to outputs that are still unspent and whose parent --- transaction is still in a live state (`pending` or `published`). +-- transaction is still in `pending` or `published` status. -- Performance: -- - Restricts first by wallet and expiration, then joins only the surviving -- lease rows back to utxos/transactions. diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql index 1dc4a0c06f..e8b7557852 100644 --- a/wallet/internal/db/queries/postgres/utxos.sql +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -39,7 +39,7 @@ RETURNING id; -- - Joins transactions on `id` so callers can address a UTXO by -- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. -- - Restricts the result to unspent outputs whose parent transaction is still --- in a live state (`pending` or `published`). +-- `pending` or `published`. -- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return -- rows whose credited address does not actually belong to the wallet. -- - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -72,8 +72,8 @@ WHERE -- the credited address belongs to the requested wallet. -- - Returns leased and unleased outputs alike because leasing affects coin -- selection, not whether the UTXO exists. --- - Treats outputs from both live unconfirmed parent states (`pending` and --- `published`) as part of the wallet's current UTXO set. +-- - Treats outputs from unmined `pending` and `published` parent transactions +-- as part of the wallet's current UTXO set. -- Performance: -- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the -- join fanout to at most one candidate output. @@ -109,8 +109,8 @@ WHERE -- ownership checks happen in the same read. -- - Returns leased outputs too because the API models leases separately from -- UTXO existence. --- - Includes outputs whose parent transaction is still in a live unconfirmed --- state (`pending` or `published`). +-- - Includes outputs whose parent transaction is still in `pending` or +-- `published` status. -- - Intentionally does not enforce coinbase maturity because this query models -- wallet-owned UTXO existence rather than a strictly spendable subset. -- Performance: @@ -182,7 +182,8 @@ ORDER BY u.amount, t.tx_hash, u.output_index; -- - Returns both the total matching value and the locked subset covered by -- active leases after the same filters are applied. -- Performance: --- - Executes as one aggregate over wallet-scoped live outputs. +-- - Executes as one aggregate over wallet-scoped outputs whose parent +-- transaction is still `pending` or `published`. -- - Uses a filtered aggregate over active leases rather than issuing a second -- query for the locked subset. -- - Uses the address/account/scope joins to keep ownership validation and @@ -284,7 +285,7 @@ ORDER BY u.spent_by_tx_id; -- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish --- between an external/dead parent and a wallet-owned conflict. +-- between an external/unknown parent and a wallet-owned conflict. -- Performance: -- - Targets one wallet-scoped outpoint through the unique `(tx_id, -- output_index)` key after the parent hash lookup. diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index 4ae01419b0..a5663a4be6 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -67,17 +67,18 @@ LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = ? AND t.tx_hash = ?; -- name: ListUnminedTransactions :many --- Lists all unconfirmed transactions for a wallet. +-- Lists the wallet transactions that still belong to the active unmined set. -- -- How: --- - Reads from transactions only and filters on blockless rows that are still --- in a live unconfirmed state (`pending` or `published`). --- - Excludes orphaned/replaced/failed history so rollback-produced coinbase --- rows do not reappear as mempool transactions. +-- - Reads from transactions only and filters on unmined rows that are still +-- in unmined `pending` or `published` status. +-- - Excludes orphaned/replaced/failed history so delete and rollback logic do +-- not treat retained invalid rows as active mempool spends. -- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` --- so the unmined row shape stays aligned with the confirmed query below. +-- so sqlc preserves the nullable block columns while the row shape stays +-- aligned with other transaction queries. -- Performance: --- - Matches the dedicated blockless-history index while the more selective +-- - Matches the dedicated unmined-history index while the more selective -- live-only partial index stays available for conflict paths. SELECT t.id, @@ -183,7 +184,7 @@ WHERE -- -- How: -- - Deletes only rows whose `block_height` is still NULL and whose status is --- still in a live unconfirmed state (`pending` or `published`). +-- still unmined `pending` or `published`. -- - Preserves orphaned/replaced/failed history; those rows must remain visible -- for audit/reorg handling instead of being treated as ordinary mempool data. -- - The caller must delete or restore dependent UTXO rows first. @@ -203,7 +204,7 @@ WHERE -- How: -- - Reads only confirmed coinbase rows at or above the rollback boundary. -- - Returns wallet scope alongside each tx hash so callers can treat these --- coinbase transactions as rollback roots when invalidating now-dead +-- coinbase transactions as rollback roots when invalidating now-invalid -- descendants inside the same rollback transaction. -- - This is a rollback-specific helper, not a generic "coinbase txs from one -- block" listing query. diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql index fa6f4eb57b..ca7be71137 100644 --- a/wallet/internal/db/queries/sqlite/utxo_leases.sql +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -6,7 +6,7 @@ -- - Resolves the outpoint to a current UTXO row and writes the lease in the -- same statement. -- - Rechecks that the outpoint is still unspent and its parent transaction is --- still in a live state (`pending` or `published`) at write time. +-- still in `pending` or `published` status at write time. -- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, -- and expired-lease takeover all happen atomically. -- Lease semantics: @@ -91,7 +91,7 @@ WHERE -- can be returned as network outpoints. -- - Filters out expired rows using the caller-supplied UTC timestamp. -- - Restricts the result to outputs that are still unspent and whose parent --- transaction is still in a live state (`pending` or `published`). +-- transaction is still in `pending` or `published` status. -- Performance: -- - Restricts first by wallet and expiration, then joins only the surviving -- lease rows back to utxos/transactions. diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index 403695abe6..04534606e5 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -39,7 +39,7 @@ RETURNING id; -- - Joins transactions on `id` so callers can address a UTXO by -- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. -- - Restricts the result to unspent outputs whose parent transaction is still --- in a live state (`pending` or `published`). +-- `pending` or `published`. -- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return -- rows whose credited address does not actually belong to the wallet. -- - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -72,8 +72,8 @@ WHERE -- the credited address belongs to the requested wallet. -- - Returns leased and unleased outputs alike because leasing affects coin -- selection, not whether the UTXO exists. --- - Treats outputs from both live unconfirmed parent states (`pending` and --- `published`) as part of the wallet's current UTXO set. +-- - Treats outputs from unmined `pending` and `published` parent transactions +-- as part of the wallet's current UTXO set. -- Performance: -- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the -- join fanout to at most one candidate output. @@ -109,8 +109,8 @@ WHERE -- ownership checks happen in the same read. -- - Returns leased outputs too because the API models leases separately from -- UTXO existence. --- - Includes outputs whose parent transaction is still in a live unconfirmed --- state (`pending` or `published`). +-- - Includes outputs whose parent transaction is still in `pending` or +-- `published` status. -- - Intentionally does not enforce coinbase maturity because this query models -- wallet-owned UTXO existence rather than a strictly spendable subset. -- Performance: @@ -182,7 +182,8 @@ ORDER BY u.amount, t.tx_hash, u.output_index; -- - Returns both the total matching value and the locked subset covered by -- active leases after the same filters are applied. -- Performance: --- - Executes as one aggregate over wallet-scoped live outputs. +-- - Executes as one aggregate over wallet-scoped outputs whose parent +-- transaction is still `pending` or `published`. -- - Uses a filtered aggregate over active leases rather than issuing a second -- query for the locked subset. -- - Uses the address/account/scope joins to keep ownership validation and @@ -289,7 +290,7 @@ ORDER BY u.spent_by_tx_id; -- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish --- between an external/dead parent and a wallet-owned conflict. +-- between an external/unknown parent and a wallet-owned conflict. -- Performance: -- - Targets one wallet-scoped outpoint through the unique `(tx_id, -- output_index)` key after the parent hash lookup. diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 073b78a071..3f54632a62 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -18,7 +18,7 @@ type Querier interface { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is - // still in a live state (`pending` or `published`) at write time. + // still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // Lease semantics: @@ -48,7 +48,8 @@ type Querier interface { // - Returns both the total matching value and the locked subset covered by // active leases after the same filters are applied. // Performance: - // - Executes as one aggregate over wallet-scoped live outputs. + // - Executes as one aggregate over wallet-scoped outputs whose parent + // transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -125,7 +126,7 @@ type Querier interface { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is - // still in a live unconfirmed state (`pending` or `published`). + // still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -208,8 +209,8 @@ type Querier interface { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. - // - Treats outputs from both live unconfirmed parent states (`pending` and - // `published`) as part of the wallet's current UTXO set. + // - Treats outputs from unmined `pending` and `published` parent transactions + // as part of the wallet's current UTXO set. // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the // join fanout to at most one candidate output. @@ -220,7 +221,7 @@ type Querier interface { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still - // in a live state (`pending` or `published`). + // `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -236,7 +237,7 @@ type Querier interface { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish - // between an external/dead parent and a wallet-owned conflict. + // between an external/unknown parent and a wallet-owned conflict. // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, // output_index)` key after the parent hash lookup. @@ -318,7 +319,7 @@ type Querier interface { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent - // transaction is still in a live state (`pending` or `published`). + // transaction is still in `pending` or `published` status. // Performance: // - Restricts first by wallet and expiration, then joins only the surviving // lease rows back to utxos/transactions. @@ -377,7 +378,7 @@ type Querier interface { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these - // coinbase transactions as rollback roots when invalidating now-dead + // coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -405,18 +406,18 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) - // Lists all unconfirmed transactions for a wallet. + // Lists the wallet transactions that still belong to the active unmined set. // // How: - // - Reads from transactions only and filters on blockless rows that are still - // in a live unconfirmed state (`pending` or `published`). - // - Excludes orphaned/replaced/failed history so rollback-produced coinbase - // rows do not reappear as mempool transactions. - // - Returns typed NULL block metadata explicitly because live unmined rows have - // no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` - // keep the unmined row shape aligned with the confirmed query below. + // - Reads from transactions only and filters on unmined rows that are still + // in unmined `pending` or `published` status. + // - Excludes orphaned/replaced/failed history so delete and rollback logic do + // not treat retained invalid rows as active mempool spends. + // - Returns typed NULL block metadata explicitly because unmined rows have no + // block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the row shape aligned with other transaction queries. // Performance: - // - Matches the dedicated blockless-history index while the more selective + // - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) // Lists unspent UTXOs that match the provided filters. @@ -429,8 +430,8 @@ type Querier interface { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. - // - Includes outputs whose parent transaction is still in a live unconfirmed - // state (`pending` or `published`). + // - Includes outputs whose parent transaction is still in `pending` or + // `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // Performance: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index 2cb9a124e7..ee87bb71e0 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -89,7 +89,7 @@ type DeleteUnminedTransactionByHashParams struct { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is -// still in a live unconfirmed state (`pending` or `published`). +// still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -289,7 +289,7 @@ type ListRollbackCoinbaseRootsRow struct { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these -// coinbase transactions as rollback roots when invalidating now-dead +// coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -437,19 +437,19 @@ type ListUnminedTransactionsRow struct { TxLabel string } -// Lists all unconfirmed transactions for a wallet. +// Lists the wallet transactions that still belong to the active unmined set. // // How: -// - Reads from transactions only and filters on blockless rows that are still -// in a live unconfirmed state (`pending` or `published`). -// - Excludes orphaned/replaced/failed history so rollback-produced coinbase -// rows do not reappear as mempool transactions. -// - Returns typed NULL block metadata explicitly because live unmined rows have -// no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` -// keep the unmined row shape aligned with the confirmed query below. +// - Reads from transactions only and filters on unmined rows that are still +// in unmined `pending` or `published` status. +// - Excludes orphaned/replaced/failed history so delete and rollback logic do +// not treat retained invalid rows as active mempool spends. +// - Returns typed NULL block metadata explicitly because unmined rows have no +// block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the row shape aligned with other transaction queries. // // Performance: -// - Matches the dedicated blockless-history index while the more selective +// - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go index 354d7b34ba..607e95362e 100644 --- a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go @@ -60,7 +60,7 @@ type AcquireUtxoLeaseParams struct { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is -// still in a live state (`pending` or `published`) at write time. +// still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // @@ -187,7 +187,7 @@ type ListActiveUtxoLeasesRow struct { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent -// transaction is still in a live state (`pending` or `published`). +// transaction is still in `pending` or `published` status. // // Performance: // - Restricts first by wallet and expiration, then joins only the surviving diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go index 2363718f03..91673e43fd 100644 --- a/wallet/internal/db/sqlc/postgres/utxos.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -109,7 +109,8 @@ type BalanceRow struct { // active leases after the same filters are applied. // // Performance: -// - Executes as one aggregate over wallet-scoped live outputs. +// - Executes as one aggregate over wallet-scoped outputs whose parent +// transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -245,8 +246,8 @@ type GetUtxoByOutpointRow struct { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. -// - Treats outputs from both live unconfirmed parent states (`pending` and -// `published`) as part of the wallet's current UTXO set. +// - Treats outputs from unmined `pending` and `published` parent transactions +// as part of the wallet's current UTXO set. // // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the @@ -294,7 +295,7 @@ type GetUtxoIDByOutpointParams struct { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still -// in a live state (`pending` or `published`). +// `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -334,7 +335,7 @@ type GetUtxoSpendByOutpointParams struct { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish -// between an external/dead parent and a wallet-owned conflict. +// between an external/unknown parent and a wallet-owned conflict. // // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, @@ -533,8 +534,8 @@ type ListUtxosRow struct { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. -// - Includes outputs whose parent transaction is still in a live unconfirmed -// state (`pending` or `published`). +// - Includes outputs whose parent transaction is still in `pending` or +// `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 5c644cbb50..d73b514a43 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -18,7 +18,7 @@ type Querier interface { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is - // still in a live state (`pending` or `published`) at write time. + // still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // Lease semantics: @@ -48,7 +48,8 @@ type Querier interface { // - Returns both the total matching value and the locked subset covered by // active leases after the same filters are applied. // Performance: - // - Executes as one aggregate over wallet-scoped live outputs. + // - Executes as one aggregate over wallet-scoped outputs whose parent + // transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -122,7 +123,7 @@ type Querier interface { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is - // still in a live unconfirmed state (`pending` or `published`). + // still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -205,8 +206,8 @@ type Querier interface { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. - // - Treats outputs from both live unconfirmed parent states (`pending` and - // `published`) as part of the wallet's current UTXO set. + // - Treats outputs from unmined `pending` and `published` parent transactions + // as part of the wallet's current UTXO set. // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the // join fanout to at most one candidate output. @@ -217,7 +218,7 @@ type Querier interface { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still - // in a live state (`pending` or `published`). + // `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -233,7 +234,7 @@ type Querier interface { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish - // between an external/dead parent and a wallet-owned conflict. + // between an external/unknown parent and a wallet-owned conflict. // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, // output_index)` key after the parent hash lookup. @@ -314,7 +315,7 @@ type Querier interface { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent - // transaction is still in a live state (`pending` or `published`). + // transaction is still in `pending` or `published` status. // Performance: // - Restricts first by wallet and expiration, then joins only the surviving // lease rows back to utxos/transactions. @@ -373,7 +374,7 @@ type Querier interface { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these - // coinbase transactions as rollback roots when invalidating now-dead + // coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -401,17 +402,18 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) - // Lists all unconfirmed transactions for a wallet. + // Lists the wallet transactions that still belong to the active unmined set. // // How: - // - Reads from transactions only and filters on blockless rows that are still - // in a live unconfirmed state (`pending` or `published`). - // - Excludes orphaned/replaced/failed history so rollback-produced coinbase - // rows do not reappear as mempool transactions. + // - Reads from transactions only and filters on unmined rows that are still + // in unmined `pending` or `published` status. + // - Excludes orphaned/replaced/failed history so delete and rollback logic do + // not treat retained invalid rows as active mempool spends. // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` - // so the unmined row shape stays aligned with the confirmed query below. + // so sqlc preserves the nullable block columns while the row shape stays + // aligned with other transaction queries. // Performance: - // - Matches the dedicated blockless-history index while the more selective + // - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) // Lists unspent UTXOs that match the provided filters. @@ -424,8 +426,8 @@ type Querier interface { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. - // - Includes outputs whose parent transaction is still in a live unconfirmed - // state (`pending` or `published`). + // - Includes outputs whose parent transaction is still in `pending` or + // `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // Performance: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index a6b28a0f11..efb46a4032 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -88,7 +88,7 @@ type DeleteUnminedTransactionByHashParams struct { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is -// still in a live unconfirmed state (`pending` or `published`). +// still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -288,7 +288,7 @@ type ListRollbackCoinbaseRootsRow struct { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these -// coinbase transactions as rollback roots when invalidating now-dead +// coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -436,18 +436,19 @@ type ListUnminedTransactionsRow struct { TxLabel string } -// Lists all unconfirmed transactions for a wallet. +// Lists the wallet transactions that still belong to the active unmined set. // // How: -// - Reads from transactions only and filters on blockless rows that are still -// in a live unconfirmed state (`pending` or `published`). -// - Excludes orphaned/replaced/failed history so rollback-produced coinbase -// rows do not reappear as mempool transactions. +// - Reads from transactions only and filters on unmined rows that are still +// in unmined `pending` or `published` status. +// - Excludes orphaned/replaced/failed history so delete and rollback logic do +// not treat retained invalid rows as active mempool spends. // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` -// so the unmined row shape stays aligned with the confirmed query below. +// so sqlc preserves the nullable block columns while the row shape stays +// aligned with other transaction queries. // // Performance: -// - Matches the dedicated blockless-history index while the more selective +// - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go index d5ece2d6ac..504def11a9 100644 --- a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -61,7 +61,7 @@ type AcquireUtxoLeaseParams struct { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is -// still in a live state (`pending` or `published`) at write time. +// still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // @@ -184,7 +184,7 @@ type ListActiveUtxoLeasesRow struct { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent -// transaction is still in a live state (`pending` or `published`). +// transaction is still in `pending` or `published` status. // // Performance: // - Restricts first by wallet and expiration, then joins only the surviving diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index 101a153c09..f7279ae252 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -114,7 +114,8 @@ type BalanceRow struct { // active leases after the same filters are applied. // // Performance: -// - Executes as one aggregate over wallet-scoped live outputs. +// - Executes as one aggregate over wallet-scoped outputs whose parent +// transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -250,8 +251,8 @@ type GetUtxoByOutpointRow struct { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. -// - Treats outputs from both live unconfirmed parent states (`pending` and -// `published`) as part of the wallet's current UTXO set. +// - Treats outputs from unmined `pending` and `published` parent transactions +// as part of the wallet's current UTXO set. // // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the @@ -299,7 +300,7 @@ type GetUtxoIDByOutpointParams struct { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still -// in a live state (`pending` or `published`). +// `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -339,7 +340,7 @@ type GetUtxoSpendByOutpointParams struct { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish -// between an external/dead parent and a wallet-owned conflict. +// between an external/unknown parent and a wallet-owned conflict. // // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, @@ -538,8 +539,8 @@ type ListUtxosRow struct { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. -// - Includes outputs whose parent transaction is still in a live unconfirmed -// state (`pending` or `published`). +// - Includes outputs whose parent transaction is still in `pending` or +// `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // From c93010f229b60fe3b30c8e6a625c0dc044831e71 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:21:39 +0800 Subject: [PATCH 158/695] wallet: update tx store interfaces Make CreateTx insert-only, replace UpdateTxLabel with a patch-style UpdateTx API, and switch CreateTx credits to a map keyed by output index. This narrows block/state ownership to UpdateTx, gives duplicate tx inserts a dedicated public error, and simplifies the caller-side credit contract. --- wallet/internal/db/data_types.go | 68 +++++++++++++++----------------- wallet/internal/db/interface.go | 41 ++++++++++++++----- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 96f294f580..01d3f31d5d 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -859,44 +859,33 @@ type CreateTxParams struct { // Label is an optional label for the transaction. Label string - // Credits lists the outputs of the transaction that are controlled by - // the wallet. - Credits []CreditData + // Credits maps wallet-owned output indexes to their display addresses. + // + // The output index is the map key, so duplicate credited outputs are + // impossible by construction. + // + // NOTE: The address value is for display only. The database layer still + // matches ownership by the output's script_pub_key + // (`params.Tx.TxOut[index].PkScript`), which is the canonical key + // used by the address schema. + Credits map[uint32]address.Address } -// CreditData contains the information needed to record a transaction credit. -// It acts as an explicit instruction to the CreateTx method, identifying which -// of the transaction's outputs belongs to the wallet and should be recorded as -// a new UTXO. This serves as a performance optimization, preventing the -// database layer from needing to parse every transaction output and query the -// address manager to determine ownership. -type CreditData struct { - // Index is the output index of the credit. - Index uint32 - - // Address is the address that received the credit. - // - // NOTE: This field is for display only. The database layer should match the - // credit to an address row by the output's script_pub_key - // (`params.Tx.TxOut[Index].PkScript`), which is the canonical key - // used by the address schema. This is especially important for - // imported or script-based addresses, where wallet ownership is - // defined by the stored script material rather than by one canonical - // encoded address string. +// UpdateTxState contains one requested transaction-state change. +type UpdateTxState struct { + // Block records the transaction as confirmed in the provided block. // - // Examples: - // - A standard P2WPKH credit usually has one obvious bech32 address for UI - // display, but the wallet still keys ownership off the exact witness - // program bytes recorded in script_pub_key. - // - An imported script address (`waddrmgr.Script`, - // `waddrmgr.WitnessScript`, or `waddrmgr.TaprootScript`) is owned because - // the wallet imported the script material; the encoded address, if - // any, is only a presentation form of that script. - Address address.Address + // Nil clears any current block assignment and returns the row to an unmined + // state. + Block *Block + + // Status is the wallet-relative transaction state to store together with + // the requested block assignment. + Status TxStatus } -// UpdateTxLabelParams contains the parameters for updating a transaction label. -type UpdateTxLabelParams struct { +// UpdateTxParams contains the mutable fields that UpdateTx may patch. +type UpdateTxParams struct { // WalletID is the ID of the wallet containing the transaction. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -906,10 +895,17 @@ type UpdateTxLabelParams struct { // Txid is the hash of the transaction to update. Txid chainhash.Hash - // Label is the new label for the transaction. + // Label optionally replaces the stored user-visible label. // - // The empty string is a valid value and clears any prior label. - Label string + // Nil leaves the label unchanged. The empty string is a valid value and + // clears any prior label. + Label *string + + // State optionally replaces the stored block/status view of the + // transaction. + // + // Nil leaves the chain-state metadata unchanged. + State *UpdateTxState } // GetTxQuery contains the parameters for querying a transaction. While a diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 506d21421a..280e23d012 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -75,13 +75,32 @@ var ( // database. ErrTxNotFound = errors.New("transaction not found") + // ErrTxAlreadyExists is returned when CreateTx is asked to insert a + // wallet-scoped transaction hash that already exists. + ErrTxAlreadyExists = errors.New("transaction already exists") + + // ErrBlockNotFound is returned when a transaction operation references a + // block height that does not exist in the shared blocks table. + ErrBlockNotFound = errors.New("block not found") + + // ErrBlockMismatch is returned when a transaction operation references a + // block height whose stored hash or timestamp does not match the supplied + // block metadata. + ErrBlockMismatch = errors.New("block metadata mismatch") + // ErrUtxoNotFound is returned when a UTXO is not found in the database. ErrUtxoNotFound = errors.New("utxo not found") // ErrTxInputConflict is returned when CreateTx references a wallet-owned - // input that is already spent by another live wallet transaction. + // input that is already claimed by another recorded wallet spend. ErrTxInputConflict = errors.New( - "transaction input conflicts with live wallet spend", + "transaction input conflicts with another wallet spend", + ) + + // ErrTxInputInvalidParent is returned when CreateTx references a wallet- + // owned input whose parent transaction is already invalid. + ErrTxInputInvalidParent = errors.New( + "transaction input spends wallet output with invalid parent", ) ) @@ -259,15 +278,17 @@ type TxStore interface { // choice explicitly in CreateTxParams. CreateTx(ctx context.Context, params CreateTxParams) error - // UpdateTxLabel updates an existing transaction record in the database. It - // takes a context and UpdateTxLabelParams, returning an error if the - // transaction cannot be found or updated. + // UpdateTx patches the mutable metadata for one existing wallet-scoped + // transaction record. + // + // UpdateTx can update the user-visible label, the chain-state view + // (block/status), or both in one atomic write. It never rewrites + // immutable transaction facts such as the serialized transaction bytes, + // created credits, or spent-input edges. // - // UpdateTxLabel is intentionally narrow: it only updates the user-visible - // label. Block assignment, rollback, and status transitions belong to - // dedicated internal queries used by wallet synchronization and - // replacement handling. - UpdateTxLabel(ctx context.Context, params UpdateTxLabelParams) error + // UpdateTx is the only public tx-store API that may attach, replace, or + // clear confirming block metadata. + UpdateTx(ctx context.Context, params UpdateTxParams) error // GetTx retrieves a transaction record by its hash. It takes a context // and GetTxQuery, returning a TxInfo struct or an error if the From 30f31418b7229245a7c1d0806a8e9b12d55ec5c2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:57:38 +0800 Subject: [PATCH 159/695] wallet: add `HasInvalidWalletUtxoByOutpoint` queries Add the wallet-owned invalid-parent lookup used by CreateTx to reject child spends of outputs whose parent transaction is already invalid. Keep the handwritten SQL and regenerated sqlc bindings together so the query contract stays reviewable in one place. --- wallet/internal/db/queries/postgres/utxos.sql | 24 ++++++++++++ wallet/internal/db/queries/sqlite/utxos.sql | 24 ++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++++ wallet/internal/db/sqlc/postgres/querier.go | 12 ++++++ wallet/internal/db/sqlc/postgres/utxos.sql.go | 39 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 10 +++++ wallet/internal/db/sqlc/sqlite/querier.go | 12 ++++++ wallet/internal/db/sqlc/sqlite/utxos.sql.go | 39 +++++++++++++++++++ 8 files changed, 170 insertions(+) diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql index e8b7557852..140d4b610a 100644 --- a/wallet/internal/db/queries/postgres/utxos.sql +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -298,6 +298,30 @@ WHERE AND u.output_index = $3 AND t.tx_status IN (0, 1); +-- name: HasInvalidWalletUtxoByOutpoint :one +-- Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +-- transaction is already invalid. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +-- for any status outside `pending`/`published`. +-- - Exists so CreateTx can reject children of wallet-owned outputs whose +-- parent transaction is already invalid. +-- Performance: +-- - Targets one wallet-scoped outpoint through the parent tx lookup plus the +-- unique `(tx_id, output_index)` key. +SELECT exists( + SELECT 1 + FROM utxos AS u + INNER JOIN transactions AS t + ON u.tx_id = t.id + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status NOT IN (0, 1) +) AS has_invalid; + -- name: MarkUtxoSpent :execrows -- Marks a wallet-owned UTXO as spent by a transaction. -- diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index 04534606e5..e361e37804 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -303,6 +303,30 @@ WHERE AND utxos.output_index = ?3 AND t.tx_status IN (0, 1); +-- name: HasInvalidWalletUtxoByOutpoint :one +-- Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +-- transaction is already invalid. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +-- for any status outside `pending`/`published`. +-- - Exists so CreateTx can reject children of wallet-owned outputs whose +-- parent transaction is already invalid. +-- Performance: +-- - Targets one wallet-scoped outpoint through the parent tx lookup plus the +-- unique `(tx_id, output_index)` key. +SELECT cast(EXISTS ( + SELECT 1 + FROM utxos + INNER JOIN transactions AS t + ON utxos.tx_id = t.id + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status NOT IN (0, 1) +) AS BOOLEAN) AS has_invalid; + -- name: MarkUtxoSpent :execrows -- Marks a wallet-owned UTXO as spent by a transaction. -- diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 2bd999a7bc..98386d7c7d 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -150,6 +150,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.hasInvalidWalletUtxoByOutpointStmt, err = db.PrepareContext(ctx, HasInvalidWalletUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query HasInvalidWalletUtxoByOutpoint: %w", err) + } if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) } @@ -476,6 +479,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.hasInvalidWalletUtxoByOutpointStmt != nil { + if cerr := q.hasInvalidWalletUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing hasInvalidWalletUtxoByOutpointStmt: %w", cerr) + } + } if q.insertAddressSecretStmt != nil { if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) @@ -742,6 +750,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + hasInvalidWalletUtxoByOutpointStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt @@ -827,6 +836,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + hasInvalidWalletUtxoByOutpointStmt: q.hasInvalidWalletUtxoByOutpointStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 3f54632a62..fdc3eb100b 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -245,6 +245,18 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Reports whether an outpoint belongs to a wallet-owned UTXO whose parent + // transaction is already invalid. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks + // for any status outside `pending`/`published`. + // - Exists so CreateTx can reject children of wallet-owned outputs whose + // parent transaction is already invalid. + // Performance: + // - Targets one wallet-scoped outpoint through the parent tx lookup plus the + // unique `(tx_id, output_index)` key. + HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) // Inserts address secret information (private key, script) for imported addresses. // Not used for derived addresses (their keys are derived from account key). InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go index 91673e43fd..7eb9ab54c9 100644 --- a/wallet/internal/db/sqlc/postgres/utxos.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -347,6 +347,45 @@ func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendBy return spent_by_tx_id, err } +const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one +SELECT exists( + SELECT 1 + FROM utxos AS u + INNER JOIN transactions AS t + ON u.tx_id = t.id + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status NOT IN (0, 1) +) AS has_invalid +` + +type HasInvalidWalletUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +// transaction is already invalid. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +// for any status outside `pending`/`published`. +// - Exists so CreateTx can reject children of wallet-owned outputs whose +// parent transaction is already invalid. +// +// Performance: +// - Targets one wallet-scoped outpoint through the parent tx lookup plus the +// unique `(tx_id, output_index)` key. +func (q *Queries) HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) { + row := q.queryRow(ctx, q.hasInvalidWalletUtxoByOutpointStmt, HasInvalidWalletUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var has_invalid bool + err := row.Scan(&has_invalid) + return has_invalid, err +} + const InsertUtxo = `-- name: InsertUtxo :one INSERT INTO utxos ( tx_id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 62018917a4..b418813772 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -150,6 +150,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.hasInvalidWalletUtxoByOutpointStmt, err = db.PrepareContext(ctx, HasInvalidWalletUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query HasInvalidWalletUtxoByOutpoint: %w", err) + } if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) } @@ -473,6 +476,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.hasInvalidWalletUtxoByOutpointStmt != nil { + if cerr := q.hasInvalidWalletUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing hasInvalidWalletUtxoByOutpointStmt: %w", cerr) + } + } if q.insertAddressSecretStmt != nil { if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) @@ -734,6 +742,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + hasInvalidWalletUtxoByOutpointStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt @@ -818,6 +827,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + hasInvalidWalletUtxoByOutpointStmt: q.hasInvalidWalletUtxoByOutpointStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index d73b514a43..f3c5f3cac1 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -242,6 +242,18 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Reports whether an outpoint belongs to a wallet-owned UTXO whose parent + // transaction is already invalid. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks + // for any status outside `pending`/`published`. + // - Exists so CreateTx can reject children of wallet-owned outputs whose + // parent transaction is already invalid. + // Performance: + // - Targets one wallet-scoped outpoint through the parent tx lookup plus the + // unique `(tx_id, output_index)` key. + HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) // Inserts address secret information (private key, script) for imported addresses. // Not used for derived addresses (their keys are derived from account key). InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index f7279ae252..6c41db8c2c 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -352,6 +352,45 @@ func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendBy return spent_by_tx_id, err } +const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one +SELECT cast(EXISTS ( + SELECT 1 + FROM utxos + INNER JOIN transactions AS t + ON utxos.tx_id = t.id + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status NOT IN (0, 1) +) AS BOOLEAN) AS has_invalid +` + +type HasInvalidWalletUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +// transaction is already invalid. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +// for any status outside `pending`/`published`. +// - Exists so CreateTx can reject children of wallet-owned outputs whose +// parent transaction is already invalid. +// +// Performance: +// - Targets one wallet-scoped outpoint through the parent tx lookup plus the +// unique `(tx_id, output_index)` key. +func (q *Queries) HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) { + row := q.queryRow(ctx, q.hasInvalidWalletUtxoByOutpointStmt, HasInvalidWalletUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var has_invalid bool + err := row.Scan(&has_invalid) + return has_invalid, err +} + const InsertUtxo = `-- name: InsertUtxo :one INSERT INTO utxos ( tx_id, From 501dca0103e69bcfdce7f9512cf61ed97bd6bef0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 16:54:03 +0800 Subject: [PATCH 160/695] wallet: add `UpdateTransactionStateByHash` queries Replace the narrow confirmation helper query with a generic transaction-state update query that can patch block assignment and status together. Keep the handwritten SQL and regenerated sqlc bindings together so the new UpdateTx query contract is reviewed in one commit. --- .../db/queries/postgres/transactions.sql | 23 ++--- .../db/queries/sqlite/transactions.sql | 23 ++--- wallet/internal/db/sqlc/postgres/db.go | 20 ++--- wallet/internal/db/sqlc/postgres/querier.go | 31 ++++--- .../db/sqlc/postgres/transactions.sql.go | 87 ++++++++++--------- wallet/internal/db/sqlc/sqlite/db.go | 20 ++--- wallet/internal/db/sqlc/sqlite/querier.go | 31 ++++--- .../db/sqlc/sqlite/transactions.sql.go | 87 ++++++++++--------- 8 files changed, 176 insertions(+), 146 deletions(-) diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 7b09412ab1..06b7796a05 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -143,25 +143,26 @@ WHERE wallet_id = sqlc.arg('wallet_id') AND tx_hash = sqlc.arg('tx_hash'); --- name: ConfirmUnminedTransactionByHash :execrows --- Attaches a confirming block to one existing live unmined transaction row. +-- name: UpdateTransactionStateByHash :execrows +-- Updates the stored block assignment and wallet-relative status for one +-- transaction row. -- -- How: --- - Updates only rows that are still blockless and live (`pending` or --- `published`). --- - Leaves user-visible metadata such as labels untouched so confirmation can --- reuse the original transaction row instead of reinserting it. +-- - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +-- inputs untouched. +-- - Leaves the user-visible label untouched so callers can patch label and +-- state independently or together inside one SQL transaction. +-- - Expects callers to validate any required block reference and state +-- invariants before issuing the update. -- Performance: -- - Updates at most one row through the wallet-scoped unique tx-hash lookup. UPDATE transactions SET - block_height = sqlc.arg('block_height')::INTEGER, - tx_status = 1 + block_height = sqlc.narg('block_height')::INTEGER, + tx_status = sqlc.arg('status') WHERE wallet_id = sqlc.arg('wallet_id') - AND tx_hash = sqlc.arg('tx_hash') - AND block_height IS NULL - AND tx_status IN (0, 1); + AND tx_hash = sqlc.arg('tx_hash'); -- name: UpdateTransactionStatusByIDs :execrows -- Updates the wallet-relative status for a set of transaction row IDs. diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index a5663a4be6..ee9a0b5fde 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -143,25 +143,26 @@ WHERE wallet_id = sqlc.arg('wallet_id') AND tx_hash = sqlc.arg('tx_hash'); --- name: ConfirmUnminedTransactionByHash :execrows --- Attaches a confirming block to one existing live unmined transaction row. +-- name: UpdateTransactionStateByHash :execrows +-- Updates the stored block assignment and wallet-relative status for one +-- transaction row. -- -- How: --- - Updates only rows that are still blockless and live (`pending` or --- `published`). --- - Leaves user-visible metadata such as labels untouched so confirmation can --- reuse the original transaction row instead of reinserting it. +-- - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +-- inputs untouched. +-- - Leaves the user-visible label untouched so callers can patch label and +-- state independently or together inside one SQL transaction. +-- - Expects callers to validate any required block reference and state +-- invariants before issuing the update. -- Performance: -- - Updates at most one row through the wallet-scoped unique tx-hash lookup. UPDATE transactions SET - block_height = cast(sqlc.arg('block_height') AS INTEGER), - tx_status = 1 + block_height = cast(sqlc.narg('block_height') AS INTEGER), + tx_status = sqlc.arg('status') WHERE wallet_id = sqlc.arg('wallet_id') - AND tx_hash = sqlc.arg('tx_hash') - AND block_height IS NULL - AND tx_status IN (0, 1); + AND tx_hash = sqlc.arg('tx_hash'); -- name: UpdateTransactionStatusByIDs :execrows -- Updates the wallet-relative status for a set of transaction row IDs. diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 98386d7c7d..8c0fcbcf38 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -33,9 +33,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) } - if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { - return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -255,6 +252,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) } + if q.updateTransactionStateByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionStateByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStateByHash: %w", err) + } if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) } @@ -284,11 +284,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) } } - if q.confirmUnminedTransactionByHashStmt != nil { - if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -654,6 +649,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) } } + if q.updateTransactionStateByHashStmt != nil { + if cerr := q.updateTransactionStateByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStateByHashStmt: %w", cerr) + } + } if q.updateTransactionStatusByIDsStmt != nil { if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) @@ -711,7 +711,6 @@ type Queries struct { acquireUtxoLeaseStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt - confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -785,6 +784,7 @@ type Queries struct { updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStateByHashStmt *sql.Stmt updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -797,7 +797,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, - confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -871,6 +870,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStateByHashStmt: q.updateTransactionStateByHashStmt, updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index fdc3eb100b..9c9a9ff2a4 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -64,16 +64,6 @@ type Querier interface { // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet // ownership through the creating transaction. ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) - // Attaches a confirming block to one existing live unmined transaction row. - // - // How: - // - Updates only rows that are still blockless and live (`pending` or - // `published`). - // - Leaves user-visible metadata such as labels untouched so confirmation can - // reuse the original transaction row instead of reinserting it. - // Performance: - // - Updates at most one row through the wallet-scoped unique tx-hash lookup. - ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -268,13 +258,15 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope and any optional - // block reference. + // - Expects the caller to have already resolved wallet scope. + // - Inserts one row with no confirming block by storing `NULL` in + // `block_height`. Later block assignment belongs to the state-update query + // below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks and any optional block foreign-key validation. + // checks. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -528,6 +520,19 @@ type Querier interface { // Performance: // - Updates at most one row through the wallet-scoped unique tx-hash lookup. UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the stored block assignment and wallet-relative status for one + // transaction row. + // + // How: + // - Leaves immutable transaction facts such as `raw_tx`, credits, and spent + // inputs untouched. + // - Leaves the user-visible label untouched so callers can patch label and + // state independently or together inside one SQL transaction. + // - Expects callers to validate any required block reference and state + // invariants before issuing the update. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) // Updates the wallet-relative status for a set of transaction row IDs. // // How: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index ee87bb71e0..90bb64d9ea 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -13,42 +13,6 @@ import ( "github.com/lib/pq" ) -const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows -UPDATE transactions -SET - block_height = $1::INTEGER, - tx_status = 1 -WHERE - wallet_id = $2 - AND tx_hash = $3 - AND block_height IS NULL - AND tx_status IN (0, 1) -` - -type ConfirmUnminedTransactionByHashParams struct { - BlockHeight int32 - WalletID int64 - TxHash []byte -} - -// Attaches a confirming block to one existing live unmined transaction row. -// -// How: -// - Updates only rows that are still blockless and live (`pending` or -// `published`). -// - Leaves user-visible metadata such as labels untouched so confirmation can -// reuse the original transaction row instead of reinserting it. -// -// Performance: -// - Updates at most one row through the wallet-scoped unique tx-hash lookup. -func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { - result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows DELETE FROM blocks WHERE block_height >= $1 @@ -243,14 +207,16 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope and any optional -// block reference. +// - Expects the caller to have already resolved wallet scope. +// - Inserts one row with no confirming block by storing `NULL` in +// `block_height`. Later block assignment belongs to the state-update query +// below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks and any optional block foreign-key validation. +// checks. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -573,6 +539,49 @@ func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTr return result.RowsAffected() } +const UpdateTransactionStateByHash = `-- name: UpdateTransactionStateByHash :execrows +UPDATE transactions +SET + block_height = $1::INTEGER, + tx_status = $2 +WHERE + wallet_id = $3 + AND tx_hash = $4 +` + +type UpdateTransactionStateByHashParams struct { + BlockHeight sql.NullInt32 + Status int16 + WalletID int64 + TxHash []byte +} + +// Updates the stored block assignment and wallet-relative status for one +// transaction row. +// +// How: +// - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +// inputs untouched. +// - Leaves the user-visible label untouched so callers can patch label and +// state independently or together inside one SQL transaction. +// - Expects callers to validate any required block reference and state +// invariants before issuing the update. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStateByHashStmt, UpdateTransactionStateByHash, + arg.BlockHeight, + arg.Status, + arg.WalletID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows UPDATE transactions SET tx_status = $1 diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index b418813772..71d4200ff6 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -33,9 +33,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) } - if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { - return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -252,6 +249,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) } + if q.updateTransactionStateByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionStateByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStateByHash: %w", err) + } if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) } @@ -281,11 +281,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) } } - if q.confirmUnminedTransactionByHashStmt != nil { - if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -646,6 +641,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) } } + if q.updateTransactionStateByHashStmt != nil { + if cerr := q.updateTransactionStateByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStateByHashStmt: %w", cerr) + } + } if q.updateTransactionStatusByIDsStmt != nil { if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) @@ -703,7 +703,6 @@ type Queries struct { acquireUtxoLeaseStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt - confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -776,6 +775,7 @@ type Queries struct { updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStateByHashStmt *sql.Stmt updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -788,7 +788,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, - confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -861,6 +860,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStateByHashStmt: q.updateTransactionStateByHashStmt, updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index f3c5f3cac1..9f422a4ab0 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -64,16 +64,6 @@ type Querier interface { // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet // ownership through the creating transaction. ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) - // Attaches a confirming block to one existing live unmined transaction row. - // - // How: - // - Updates only rows that are still blockless and live (`pending` or - // `published`). - // - Leaves user-visible metadata such as labels untouched so confirmation can - // reuse the original transaction row instead of reinserting it. - // Performance: - // - Updates at most one row through the wallet-scoped unique tx-hash lookup. - ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -265,13 +255,15 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope and any optional - // block reference. + // - Expects the caller to have already resolved wallet scope. + // - Inserts one row with no confirming block by storing `NULL` in + // `block_height`. Later block assignment belongs to the state-update query + // below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks and any optional block foreign-key validation. + // checks. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -500,6 +492,19 @@ type Querier interface { // Performance: // - Updates at most one row through the wallet-scoped unique tx-hash lookup. UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the stored block assignment and wallet-relative status for one + // transaction row. + // + // How: + // - Leaves immutable transaction facts such as `raw_tx`, credits, and spent + // inputs untouched. + // - Leaves the user-visible label untouched so callers can patch label and + // state independently or together inside one SQL transaction. + // - Expects callers to validate any required block reference and state + // invariants before issuing the update. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) // Updates the wallet-relative status for a set of transaction row IDs. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index efb46a4032..4067594bec 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -12,42 +12,6 @@ import ( "time" ) -const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows -UPDATE transactions -SET - block_height = cast(?1 AS INTEGER), - tx_status = 1 -WHERE - wallet_id = ?2 - AND tx_hash = ?3 - AND block_height IS NULL - AND tx_status IN (0, 1) -` - -type ConfirmUnminedTransactionByHashParams struct { - BlockHeight int64 - WalletID int64 - TxHash []byte -} - -// Attaches a confirming block to one existing live unmined transaction row. -// -// How: -// - Updates only rows that are still blockless and live (`pending` or -// `published`). -// - Leaves user-visible metadata such as labels untouched so confirmation can -// reuse the original transaction row instead of reinserting it. -// -// Performance: -// - Updates at most one row through the wallet-scoped unique tx-hash lookup. -func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { - result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows DELETE FROM blocks WHERE block_height >= ? @@ -242,14 +206,16 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope and any optional -// block reference. +// - Expects the caller to have already resolved wallet scope. +// - Inserts one row with no confirming block by storing `NULL` in +// `block_height`. Later block assignment belongs to the state-update query +// below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks and any optional block foreign-key validation. +// checks. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -572,6 +538,49 @@ func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTr return result.RowsAffected() } +const UpdateTransactionStateByHash = `-- name: UpdateTransactionStateByHash :execrows +UPDATE transactions +SET + block_height = cast(?1 AS INTEGER), + tx_status = ?2 +WHERE + wallet_id = ?3 + AND tx_hash = ?4 +` + +type UpdateTransactionStateByHashParams struct { + BlockHeight sql.NullInt64 + Status int64 + WalletID int64 + TxHash []byte +} + +// Updates the stored block assignment and wallet-relative status for one +// transaction row. +// +// How: +// - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +// inputs untouched. +// - Leaves the user-visible label untouched so callers can patch label and +// state independently or together inside one SQL transaction. +// - Expects callers to validate any required block reference and state +// invariants before issuing the update. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStateByHashStmt, UpdateTransactionStateByHash, + arg.BlockHeight, + arg.Status, + arg.WalletID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows UPDATE transactions SET tx_status = ?1 From 1f7d30b71c0ff4c9ddef8f2f1be5c7412cc07945 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 14:04:33 +0800 Subject: [PATCH 161/695] wallet: add `ListTransactionsWithoutBlock` queries Add the dedicated no-block transaction query used by ListTxns when callers request wallet history without a confirming block. Keeping the handwritten SQL and regenerated sqlc bindings together makes the new read path reviewable without mixing in the Go listing logic. --- .../db/queries/postgres/transactions.sql | 30 +++++++ .../db/queries/sqlite/transactions.sql | 31 +++++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++ wallet/internal/db/sqlc/postgres/querier.go | 21 +++-- .../db/sqlc/postgres/transactions.sql.go | 87 ++++++++++++++++-- wallet/internal/db/sqlc/sqlite/db.go | 10 +++ wallet/internal/db/sqlc/sqlite/querier.go | 21 +++-- .../db/sqlc/sqlite/transactions.sql.go | 88 +++++++++++++++++-- 8 files changed, 278 insertions(+), 20 deletions(-) diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 06b7796a05..027553bdba 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -66,6 +66,36 @@ FROM transactions AS t LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = $1 AND t.tx_hash = $2; +-- name: ListTransactionsWithoutBlock :many +-- Lists every wallet transaction row that currently has no confirming block. +-- +-- How: +-- - Reads from transactions only and filters on rows with no confirming block. +-- - Includes the active unmined set (`pending` and `published`) together with +-- retained invalid history such as `failed`, `replaced`, or `orphaned` +-- rows. +-- - Returns typed NULL block metadata explicitly because unmined rows have no +-- block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the row shape aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated no-confirming-block history index. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC; + -- name: ListUnminedTransactions :many -- Lists the wallet transactions that still belong to the active unmined set. -- diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index ee9a0b5fde..2b1b14f4e6 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -66,6 +66,37 @@ FROM transactions AS t LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = ? AND t.tx_hash = ?; +-- name: ListTransactionsWithoutBlock :many +-- Lists every wallet transaction row that currently has no confirming block. +-- +-- How: +-- - Reads from transactions only and filters on rows with no confirming block. +-- - Includes the active unmined set (`pending` and `published`) together with +-- retained invalid history such as `failed`, `replaced`, or `orphaned` +-- rows. +-- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +-- so sqlc preserves the nullable block columns while the row shape stays +-- aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated no-confirming-block history index. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC; + -- name: ListUnminedTransactions :many -- Lists the wallet transactions that still belong to the active unmined set. -- diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 8c0fcbcf38..19e8837f3d 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -222,6 +222,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) } + if q.listTransactionsWithoutBlockStmt, err = db.PrepareContext(ctx, ListTransactionsWithoutBlock); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsWithoutBlock: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -599,6 +602,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) } } + if q.listTransactionsWithoutBlockStmt != nil { + if cerr := q.listTransactionsWithoutBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsWithoutBlockStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -774,6 +782,7 @@ type Queries struct { listRollbackCoinbaseRootsStmt *sql.Stmt listSpendingTxIDsByParentTxIDStmt *sql.Stmt listTransactionsByHeightRangeStmt *sql.Stmt + listTransactionsWithoutBlockStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt @@ -860,6 +869,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listTransactionsWithoutBlockStmt: q.listTransactionsWithoutBlockStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 9c9a9ff2a4..94d35afc83 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -258,15 +258,13 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope. - // - Inserts one row with no confirming block by storing `NULL` in - // `block_height`. Later block assignment belongs to the state-update query - // below. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks. + // checks and any optional block foreign-key validation. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -410,6 +408,19 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists every wallet transaction row that currently has no confirming block. + // + // How: + // - Reads from transactions only and filters on rows with no confirming block. + // - Includes the active unmined set (`pending` and `published`) together with + // retained invalid history such as `failed`, `replaced`, or `orphaned` + // rows. + // - Returns typed NULL block metadata explicitly because unmined rows have no + // block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the row shape aligned with the confirmed query below. + // Performance: + // - Matches the dedicated no-confirming-block history index. + ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) // Lists the wallet transactions that still belong to the active unmined set. // // How: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index 90bb64d9ea..c4406f509d 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -207,16 +207,14 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope. -// - Inserts one row with no confirming block by storing `NULL` in -// `block_height`. Later block assignment belongs to the state-update query -// below. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks. +// checks and any optional block foreign-key validation. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -370,6 +368,85 @@ func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTra return items, nil } +const ListTransactionsWithoutBlock = `-- name: ListTransactionsWithoutBlock :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC +` + +type ListTransactionsWithoutBlockRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists every wallet transaction row that currently has no confirming block. +// +// How: +// - Reads from transactions only and filters on rows with no confirming block. +// - Includes the active unmined set (`pending` and `published`) together with +// retained invalid history such as `failed`, `replaced`, or `orphaned` +// rows. +// - Returns typed NULL block metadata explicitly because unmined rows have no +// block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the row shape aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated no-confirming-block history index. +func (q *Queries) ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) { + rows, err := q.query(ctx, q.listTransactionsWithoutBlockStmt, ListTransactionsWithoutBlock, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsWithoutBlockRow + for rows.Next() { + var i ListTransactionsWithoutBlockRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT t.id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 71d4200ff6..d5fa946fc3 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -222,6 +222,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) } + if q.listTransactionsWithoutBlockStmt, err = db.PrepareContext(ctx, ListTransactionsWithoutBlock); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsWithoutBlock: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -596,6 +599,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) } } + if q.listTransactionsWithoutBlockStmt != nil { + if cerr := q.listTransactionsWithoutBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsWithoutBlockStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -766,6 +774,7 @@ type Queries struct { listRollbackCoinbaseRootsStmt *sql.Stmt listSpendingTxIDsByParentTxIDStmt *sql.Stmt listTransactionsByHeightRangeStmt *sql.Stmt + listTransactionsWithoutBlockStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt @@ -851,6 +860,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listTransactionsWithoutBlockStmt: q.listTransactionsWithoutBlockStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 9f422a4ab0..a7345d54e8 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -255,15 +255,13 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope. - // - Inserts one row with no confirming block by storing `NULL` in - // `block_height`. Later block assignment belongs to the state-update query - // below. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks. + // checks and any optional block foreign-key validation. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -406,6 +404,19 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists every wallet transaction row that currently has no confirming block. + // + // How: + // - Reads from transactions only and filters on rows with no confirming block. + // - Includes the active unmined set (`pending` and `published`) together with + // retained invalid history such as `failed`, `replaced`, or `orphaned` + // rows. + // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` + // so sqlc preserves the nullable block columns while the row shape stays + // aligned with the confirmed query below. + // Performance: + // - Matches the dedicated no-confirming-block history index. + ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) // Lists the wallet transactions that still belong to the active unmined set. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index 4067594bec..07ef498aa1 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -206,16 +206,14 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope. -// - Inserts one row with no confirming block by storing `NULL` in -// `block_height`. Later block assignment belongs to the state-update query -// below. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks. +// checks and any optional block foreign-key validation. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -368,6 +366,86 @@ func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTra return items, nil } +const ListTransactionsWithoutBlock = `-- name: ListTransactionsWithoutBlock :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC +` + +type ListTransactionsWithoutBlockRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists every wallet transaction row that currently has no confirming block. +// +// How: +// - Reads from transactions only and filters on rows with no confirming block. +// - Includes the active unmined set (`pending` and `published`) together with +// retained invalid history such as `failed`, `replaced`, or `orphaned` +// rows. +// - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +// so sqlc preserves the nullable block columns while the row shape stays +// aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated no-confirming-block history index. +func (q *Queries) ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) { + rows, err := q.query(ctx, q.listTransactionsWithoutBlockStmt, ListTransactionsWithoutBlock, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsWithoutBlockRow + for rows.Next() { + var i ListTransactionsWithoutBlockRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT t.id, From 34a3539570fc72c68d2d68559bc7fb5fe7e31220 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 24 Mar 2026 14:03:24 +0800 Subject: [PATCH 162/695] wallet: fix sqlite lease query parameters Fix the sqlite AcquireUtxoLease query so the now_utc parameter is expanded inside the ON CONFLICT update predicate. Without this change sqlc leaves a raw sqlc.arg expression in the generated statement and sqlite rejects the lease write with a syntax error. Keeping the query fix in its own SQL commit makes the following LeaseOutput implementation commit a pure backend-wiring and test change. --- wallet/internal/db/queries/sqlite/utxo_leases.sql | 5 ++++- wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql index ca7be71137..f780bc3cde 100644 --- a/wallet/internal/db/queries/sqlite/utxo_leases.sql +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -49,7 +49,10 @@ SET WHERE utxo_leases.wallet_id = excluded.wallet_id AND ( - utxo_leases.expires_at <= sqlc.arg('now_utc') + -- ?6 is the generated bind slot for now_utc; sqlc leaves a literal + -- sqlc.arg('now_utc') here, so this predicate must stay aligned with + -- the arg ordering above. + utxo_leases.expires_at <= ?6 OR utxo_leases.lock_id = excluded.lock_id ) RETURNING expires_at; diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go index 504def11a9..3fc475ca72 100644 --- a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -39,7 +39,10 @@ SET WHERE utxo_leases.wallet_id = excluded.wallet_id AND ( - utxo_leases.expires_at <= sqlc.arg('now_utc') + -- ?6 is the generated bind slot for now_utc; sqlc leaves a literal + -- sqlc.arg('now_utc') here, so this predicate must stay aligned with + -- the arg ordering above. + utxo_leases.expires_at <= ?6 OR utxo_leases.lock_id = excluded.lock_id ) RETURNING expires_at From 7f2dbf70465351adddd2ce563f255d5e0644d119 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:54:12 +0800 Subject: [PATCH 163/695] wallet: add tx store itest helpers Add backend-specific tx store itest helpers for looking up stored tx rows, checking spend edges, mutating internal status, and asserting wallet UTXO presence. These helpers keep the later tx store integration tests focused on behavior instead of repeating low-level query plumbing. --- wallet/internal/db/itest/pg_test.go | 124 +++++++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 125 ++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index c400b5855a..6cc14accc2 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -5,6 +5,7 @@ package itest import ( "context" "database/sql" + "errors" "flag" "fmt" "os" @@ -16,7 +17,10 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -232,3 +236,123 @@ func NewTestStore(t *testing.T) *db.PostgresStore { return store } + +// childSpendingTxIDs returns the direct child transaction IDs recorded for the +// provided parent transaction hash. +func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) []int64 { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( + t.Context(), sqlcpg.ListSpendingTxIDsByParentTxIDParams{ + WalletID: int64(walletID), + TxID: meta.ID, + }, + ) + require.NoError(t, err) + + ids := make([]int64, 0, len(childIDs)) + for _, childID := range childIDs { + require.True(t, childID.Valid) + ids = append(ids, childID.Int64) + } + + return ids +} + +// txIDByHash returns the database row ID for the given wallet-scoped +// transaction hash and reports whether the row exists. +func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) (int64, bool) { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false + } + + require.NoError(t, err) + } + + return meta.ID, true +} + +// rawTxByHash returns the serialized transaction bytes for the given +// wallet-scoped transaction hash. +func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) []byte { + + t.Helper() + + row, err := store.Queries().GetTransactionByHash( + t.Context(), sqlcpg.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + return row.RawTx +} + +// setTxStatus rewrites one wallet-scoped transaction row to the provided +// status using the internal status-update query. +func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash, status db.TxStatus) { + + t.Helper() + + txID, ok := txIDByHash(t, store, walletID, txHash) + require.True(t, ok) + + rows, err := store.Queries().UpdateTransactionStatusByIDs( + t.Context(), sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: int64(walletID), + Status: int16(status), + TxIds: []int64{txID}, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// walletUtxoExists reports whether one wallet-scoped outpoint is currently +// present in the UTXO set. +func walletUtxoExists(t *testing.T, store *db.PostgresStore, walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + _, err := store.Queries().GetUtxoIDByOutpoint( + t.Context(), sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(walletID), + TxHash: outPoint.Hash[:], + OutputIndex: int32(outPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false + } + + require.NoError(t, err) + } + + return true +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 30bc4f0797..519b1884d0 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -3,10 +3,15 @@ package itest import ( + "database/sql" + "errors" "path/filepath" "testing" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -32,3 +37,123 @@ func NewTestStore(t *testing.T) *db.SqliteStore { return store } + +// childSpendingTxIDs returns the direct child transaction IDs recorded for the +// provided parent transaction hash. +func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) []int64 { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( + t.Context(), sqlcsqlite.ListSpendingTxIDsByParentTxIDParams{ + WalletID: int64(walletID), + TxID: meta.ID, + }, + ) + require.NoError(t, err) + + ids := make([]int64, 0, len(childIDs)) + for _, childID := range childIDs { + require.True(t, childID.Valid) + ids = append(ids, childID.Int64) + } + + return ids +} + +// txIDByHash returns the database row ID for the given wallet-scoped +// transaction hash and reports whether the row exists. +func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) (int64, bool) { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false + } + + require.NoError(t, err) + } + + return meta.ID, true +} + +// rawTxByHash returns the serialized transaction bytes for the given +// wallet-scoped transaction hash. +func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) []byte { + + t.Helper() + + row, err := store.Queries().GetTransactionByHash( + t.Context(), sqlcsqlite.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + return row.RawTx +} + +// setTxStatus rewrites one wallet-scoped transaction row to the provided +// status using the internal status-update query. +func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash, status db.TxStatus) { + + t.Helper() + + txID, ok := txIDByHash(t, store, walletID, txHash) + require.True(t, ok) + + rows, err := store.Queries().UpdateTransactionStatusByIDs( + t.Context(), sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: int64(walletID), + Status: int64(status), + TxIds: []int64{txID}, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// walletUtxoExists reports whether one wallet-scoped outpoint is currently +// present in the UTXO set. +func walletUtxoExists(t *testing.T, store *db.SqliteStore, walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + _, err := store.Queries().GetUtxoIDByOutpoint( + t.Context(), sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(walletID), + TxHash: outPoint.Hash[:], + OutputIndex: int64(outPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false + } + + require.NoError(t, err) + } + + return true +} From 3bc73eab16b3abcf83043e712e2d981edd410f16 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:56:33 +0800 Subject: [PATCH 164/695] wallet: add tx store common helpers Add the shared transaction serialization, status parsing, and TxInfo conversion helpers used by both SQL backends. Keeping these small helpers and their unit tests in one place lets the later tx store method commits focus on backend behavior instead of repeating the same normalization logic. --- wallet/internal/db/tx_store_common.go | 99 +++++++++++++++ wallet/internal/db/tx_store_common_test.go | 139 +++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 wallet/internal/db/tx_store_common.go create mode 100644 wallet/internal/db/tx_store_common_test.go diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go new file mode 100644 index 0000000000..d2c55ad9e0 --- /dev/null +++ b/wallet/internal/db/tx_store_common.go @@ -0,0 +1,99 @@ +package db + +import ( + "bytes" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +var ( + // ErrInvalidParam is returned when a TxStore method receives invalid input. + ErrInvalidParam = errors.New("invalid param") + + // ErrInvalidStatus is returned when a transaction status is unknown or not + // allowed for the requested operation. + ErrInvalidStatus = errors.New("invalid transaction status") +) + +// serializeMsgTx serializes a wire.MsgTx so it can be stored in the +// transactions table. +func serializeMsgTx(tx *wire.MsgTx) ([]byte, error) { + if tx == nil { + return nil, fmt.Errorf("%w: tx is required", ErrInvalidParam) + } + + var buf bytes.Buffer + + err := tx.Serialize(&buf) + if err != nil { + return nil, fmt.Errorf("serialize tx: %w", err) + } + + return buf.Bytes(), nil +} + +// deserializeMsgTx deserializes a stored transaction payload back into a +// wire.MsgTx. +func deserializeMsgTx(rawTx []byte) (*wire.MsgTx, error) { + var tx wire.MsgTx + + err := tx.Deserialize(bytes.NewReader(rawTx)) + if err != nil { + return nil, fmt.Errorf("deserialize tx: %w", err) + } + + return &tx, nil +} + +// parseTxStatus converts a stored numeric status code into the strongly typed +// TxStatus enum used by the public db API. +func parseTxStatus(status int64) (TxStatus, error) { + txStatus, err := int64ToUint8(status) + if err != nil { + return TxStatus(0), fmt.Errorf("status %d: %w", status, + ErrInvalidStatus) + } + + switch TxStatus(txStatus) { + case TxStatusPending, + TxStatusPublished, + TxStatusReplaced, + TxStatusFailed, + TxStatusOrphaned: + + return TxStatus(txStatus), nil + + default: + return TxStatus(0), fmt.Errorf("status %d: %w", status, + ErrInvalidStatus) + } +} + +// buildTxInfo converts normalized transaction fields into the public TxInfo +// shape returned by the db interfaces. +func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, + status int64, label string) (*TxInfo, error) { + + txHash, err := chainhash.NewHash(hash) + if err != nil { + return nil, fmt.Errorf("tx hash: %w", err) + } + + txStatus, err := parseTxStatus(status) + if err != nil { + return nil, err + } + + return &TxInfo{ + Hash: *txHash, + SerializedTx: rawTx, + Received: received.UTC(), + Block: block, + Status: txStatus, + Label: label, + }, nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go new file mode 100644 index 0000000000..ecec7dcf03 --- /dev/null +++ b/wallet/internal/db/tx_store_common_test.go @@ -0,0 +1,139 @@ +package db + +import ( + "bytes" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +// TestSerializeDeserializeMsgTx verifies that the common serialization helpers +// preserve transaction bytes across a round trip. +func TestSerializeDeserializeMsgTx(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + decoded, err := deserializeMsgTx(rawTx) + require.NoError(t, err) + + var got bytes.Buffer + + err = decoded.Serialize(&got) + require.NoError(t, err) + + require.Equal(t, rawTx, got.Bytes()) +} + +// TestSerializeMsgTxNil verifies that serializeMsgTx rejects a missing +// transaction pointer with the public invalid-param error. +func TestSerializeMsgTxNil(t *testing.T) { + t.Parallel() + + _, err := serializeMsgTx(nil) + require.ErrorIs(t, err, ErrInvalidParam) +} + +// TestParseTxStatus verifies that stored numeric values map back to the public +// TxStatus enum and that unknown values fail loudly. +func TestParseTxStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int64 + want TxStatus + wantErr error + }{ + {name: "pending", status: 0, want: TxStatusPending}, + {name: "published", status: 1, want: TxStatusPublished}, + {name: "replaced", status: 2, want: TxStatusReplaced}, + {name: "failed", status: 3, want: TxStatusFailed}, + {name: "orphaned", status: 4, want: TxStatusOrphaned}, + {name: "invalid", status: 9, wantErr: ErrInvalidStatus}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseTxStatus(tc.status) + require.ErrorIs(t, err, tc.wantErr) + require.Equal(t, tc.want, got) + }) + } +} + +// TestBuildTxInfo verifies the shared row-to-domain conversion used by both +// SQL backends when returning a valid TxInfo value. +func TestBuildTxInfo(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + hash := tx.TxHash() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + blockHash := chainhash.Hash{1, 2, 3} + block := &Block{ + Hash: blockHash, + Height: 77, + Timestamp: time.Unix(500, 0), + } + + info, err := buildTxInfo( + hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), + block, int64(TxStatusPublished), "note", + ) + require.NoError(t, err) + require.Equal(t, hash, info.Hash) + require.Equal(t, rawTx, info.SerializedTx) + require.Equal(t, TxStatusPublished, info.Status) + require.Equal(t, "note", info.Label) + require.Equal(t, time.UTC, info.Received.Location()) + require.Equal(t, block, info.Block) +} + +// TestBuildTxInfoInvalidHash verifies that buildTxInfo rejects malformed hash +// bytes. +func TestBuildTxInfoInvalidHash(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + _, err = buildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, + int64(TxStatusPending), "") + require.Error(t, err) +} + +// TestBuildTxInfoInvalidStatus verifies that buildTxInfo rejects unknown status +// codes. +func TestBuildTxInfoInvalidStatus(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + hash := tx.TxHash() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + _, err = buildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") + require.ErrorIs(t, err, ErrInvalidStatus) +} + +// testRegularMsgTx builds a minimal non-coinbase transaction fixture for the +// shared TxStore helper tests. +func testRegularMsgTx() *wire.MsgTx { + tx := wire.NewMsgTx(wire.TxVersion) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}}) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + return tx +} From 5986a60d6a1ce1d536d6fb2060ae3085c5c46222 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:56:43 +0800 Subject: [PATCH 165/695] wallet: add CreateTx ops Add the shared CreateTx validation and orchestration helpers used by both SQL backends. This version validates block-aware create invariants up front so confirmed inserts and confirmed coinbase history can be recorded in one atomic write without a later patch step. --- wallet/internal/db/tx_store_common.go | 232 +++++++++++++++ wallet/internal/db/tx_store_common_test.go | 327 ++++++++++++++++++++- 2 files changed, 558 insertions(+), 1 deletion(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index d2c55ad9e0..7300d82ead 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -2,10 +2,12 @@ package db import ( "bytes" + "context" "errors" "fmt" "time" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" ) @@ -17,6 +19,14 @@ var ( // ErrInvalidStatus is returned when a transaction status is unknown or not // allowed for the requested operation. ErrInvalidStatus = errors.New("invalid transaction status") + + // ErrIndexOutOfRange is returned when a referenced transaction input or + // output index does not exist. + ErrIndexOutOfRange = errors.New("index out of range") + + // ErrDuplicateInputOutPoint is returned when CreateTx receives the same + // previous outpoint more than once. + ErrDuplicateInputOutPoint = errors.New("duplicate input outpoint") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the @@ -97,3 +107,225 @@ func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, Label: label, }, nil } + +// validateCreateTxParams enforces the CreateTx invariants shared by both SQL +// backends after serializeMsgTx has already verified that params.Tx is non-nil. +func validateCreateTxParams(params CreateTxParams) error { + isCoinbase := blockchain.IsCoinBaseTx(params.Tx) + + err := validateCreateTxStatus( + params.Status, params.Block != nil, isCoinbase, + ) + if err != nil { + return err + } + + maxIndex := uint64(len(params.Tx.TxOut)) + + for index := range params.Credits { + if uint64(index) >= maxIndex { + return fmt.Errorf("%w: credit index %d is out of range: %w", + ErrInvalidParam, index, ErrIndexOutOfRange) + } + } + + // Coinbase transactions only enter wallet history once a block already + // anchors them, so CreateTx requires the caller to provide that block up + // front instead of storing a fake unmined intermediate row first. + if isCoinbase { + return nil + } + + seenInputs := make(map[wire.OutPoint]struct{}, len(params.Tx.TxIn)) + for inputIndex, txIn := range params.Tx.TxIn { + // One transaction cannot spend the same previous outpoint twice. + // Rejecting duplicate inputs here keeps the later wallet-spend walk + // simple and avoids writing contradictory spend metadata. + if _, ok := seenInputs[txIn.PreviousOutPoint]; ok { + return fmt.Errorf("%w: input %d duplicates a previous outpoint: %w", + ErrInvalidParam, inputIndex, ErrDuplicateInputOutPoint) + } + + seenInputs[txIn.PreviousOutPoint] = struct{}{} + } + + return nil +} + +// validateCreateTxStatus checks the status/block combinations that CreateTx may +// store directly. +func validateCreateTxStatus(status TxStatus, hasBlock bool, + isCoinbase bool) error { + + _, err := parseTxStatus(int64(status)) + if err != nil { + return fmt.Errorf("%w: status %d is not supported: %w", + ErrInvalidParam, status, ErrInvalidStatus) + } + + // Orphaned rows only arise later when rollback disconnects a confirmed + // coinbase transaction. CreateTx records the initial observed facts, so it + // never inserts orphaned history directly. + if status == TxStatusOrphaned { + return fmt.Errorf("%w: CreateTx cannot insert orphaned txns: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + if !hasBlock { + // Coinbase transactions cannot exist without a confirming block from + // the store's point of view, so callers must supply that block up + // front. + if isCoinbase { + return fmt.Errorf("%w: coinbase txns require a block: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // Unmined non-coinbase inserts still represent current unmined wallet + // history, so CreateTx only accepts the two active unmined statuses + // there. + if status != TxStatusPending && status != TxStatusPublished { + return fmt.Errorf("%w: CreateTx requires pending or published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil + } + + // A non-nil block means the caller already knows the transaction is mined. + // Mined rows must be published immediately to satisfy the transaction-state + // invariants enforced by the schema. + if status != TxStatusPublished { + return fmt.Errorf("%w: confirmed txns must be published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil +} + +// createTxRequest captures the backend-independent CreateTx inputs after the +// shared validation and normalization step has already succeeded. +type createTxRequest struct { + // params keeps the original public request available for backend helpers + // that still need the caller-supplied CreateTx metadata. + params CreateTxParams + + // rawTx stores the serialized transaction bytes once so both backends reuse + // the same payload throughout the write. + rawTx []byte + + // txHash avoids recomputing the transaction hash across the shared flow and + // backend adapters. + txHash chainhash.Hash + + // received is normalized to UTC before any backend insert logic runs. + received time.Time + + // isCoinbase caches the consensus coinbase check for backend insert params. + isCoinbase bool +} + +// newCreateTxRequest performs the backend-independent CreateTx preparation +// shared by both SQL stores before they open a write transaction. +func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { + rawTx, err := serializeMsgTx(params.Tx) + if err != nil { + return createTxRequest{}, err + } + + err = validateCreateTxParams(params) + if err != nil { + return createTxRequest{}, err + } + + return createTxRequest{ + params: params, + rawTx: rawTx, + txHash: params.Tx.TxHash(), + received: params.Received.UTC(), + isCoinbase: blockchain.IsCoinBaseTx(params.Tx), + }, nil +} + +// createTxOps is the small semantic adapter CreateTx needs from one SQL +// backend. +// +// The shared CreateTx algorithm is intentionally linear: +// - load any existing wallet-scoped row for the same tx hash first +// - if the same tx is being reconfirmed, update that existing row instead of +// inserting a duplicate +// - validate and cache any confirming block metadata before later writes use +// it +// - when the incoming tx is confirmed, discover and invalidate any direct +// conflict roots before the new row claims their wallet-owned inputs +// - insert the base transaction row exactly once when no existing row can be +// reused +// - insert every wallet-owned credited output as a UTXO +// - attach any wallet-owned spent inputs to that final transaction row +// +// Each backend implements those steps with its own sqlc-generated query types +// while createTxWithOps keeps the high-level sequencing in one place. +// That sequencing matters because confirmation, conflict invalidation, credit +// creation, and spent-input claims must either all observe the same tx row or +// all roll back together. +type createTxOps interface { + // hasExisting reports whether CreateTx would collide with an existing + // wallet-scoped transaction row for the same hash. + hasExisting(ctx context.Context, req createTxRequest) (bool, error) + + // prepareBlock validates and caches any optional confirming block metadata + // the later insert step needs. + prepareBlock(ctx context.Context, req createTxRequest) error + + // insert writes the base transaction row and returns its new primary key. + insert(ctx context.Context, req createTxRequest) (int64, error) + + // insertCredits records every wallet-owned output that the caller + // marked as a credit for this transaction. + insertCredits(ctx context.Context, req createTxRequest, txID int64) error + + // markInputsSpent attaches wallet-owned parent outpoints to this + // transaction row and rejects conflicts or invalid wallet parents. + markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error +} + +// createTxWithOps runs the backend-independent CreateTx orchestration once the +// caller has opened a backend-specific SQL transaction. +// +// The helper inserts the base transaction row before it records credits or +// spent inputs so every later step can point at one stable row ID. If any later +// step fails, ExecuteTx rolls the whole write back. +func createTxWithOps(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + exists, err := ops.hasExisting(ctx, req) + if err != nil { + return fmt.Errorf("prepare create tx: check existing tx: %w", err) + } + + if exists { + return fmt.Errorf("prepare create tx: tx %s: %w", req.txHash, + ErrTxAlreadyExists) + } + + err = ops.prepareBlock(ctx, req) + if err != nil { + return fmt.Errorf("prepare create block assignment: %w", err) + } + + txID, err := ops.insert(ctx, req) + if err != nil { + return fmt.Errorf("insert tx: %w", err) + } + + err = ops.insertCredits(ctx, req, txID) + if err != nil { + return fmt.Errorf("create tx credits: %w", err) + } + + err = ops.markInputsSpent(ctx, req, txID) + if err != nil { + return fmt.Errorf("create tx spends: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index ecec7dcf03..4ba9ee348a 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -2,9 +2,11 @@ package db import ( "bytes" + "context" "testing" "time" + "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/require" @@ -15,8 +17,10 @@ import ( func TestSerializeDeserializeMsgTx(t *testing.T) { t.Parallel() + // Arrange: Build one regular transaction fixture. tx := testRegularMsgTx() + // Act: Serialize it and deserialize the result. rawTx, err := serializeMsgTx(tx) require.NoError(t, err) @@ -28,6 +32,7 @@ func TestSerializeDeserializeMsgTx(t *testing.T) { err = decoded.Serialize(&got) require.NoError(t, err) + // Assert: The decoded transaction serializes back to the same bytes. require.Equal(t, rawTx, got.Bytes()) } @@ -42,6 +47,9 @@ func TestSerializeMsgTxNil(t *testing.T) { // TestParseTxStatus verifies that stored numeric values map back to the public // TxStatus enum and that unknown values fail loudly. +// +// The table keeps the setup identical for every case so the loop only varies +// the input status code and the expected result. func TestParseTxStatus(t *testing.T) { t.Parallel() @@ -75,6 +83,7 @@ func TestParseTxStatus(t *testing.T) { func TestBuildTxInfo(t *testing.T) { t.Parallel() + // Arrange: Build one serialized transaction and one block fixture. tx := testRegularMsgTx() hash := tx.TxHash() rawTx, err := serializeMsgTx(tx) @@ -87,11 +96,14 @@ func TestBuildTxInfo(t *testing.T) { Timestamp: time.Unix(500, 0), } + // Act: Convert the normalized row fields into TxInfo. info, err := buildTxInfo( hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), block, int64(TxStatusPublished), "note", ) require.NoError(t, err) + + // Assert: The resulting TxInfo preserves the expected public fields. require.Equal(t, hash, info.Hash) require.Equal(t, rawTx, info.SerializedTx) require.Equal(t, TxStatusPublished, info.Status) @@ -128,12 +140,325 @@ func TestBuildTxInfoInvalidStatus(t *testing.T) { require.ErrorIs(t, err, ErrInvalidStatus) } +// TestValidateCreateTxParams verifies the shared CreateTx invariants that both +// SQL backends rely on before opening a write transaction. +// +// The cases vary only the CreateTx input data so the table-driven structure +// stays clear and does not need conditional setup inside the loop. +func TestValidateCreateTxParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params CreateTxParams + wantErr error + wantParamErr error + }{ + { + name: "coinbase must be published", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Block: testBlock(100), + Status: TxStatusPending, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "coinbase requires block", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Status: TxStatusPublished, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "orphaned status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusOrphaned, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "failed status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusFailed, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "replaced status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusReplaced, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "credit index out of range", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Credits: map[uint32]address.Address{2: nil}, + Status: TxStatusPending, + }, + wantErr: ErrIndexOutOfRange, + wantParamErr: ErrInvalidParam, + }, + { + name: "duplicate input outpoint", + params: CreateTxParams{ + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }, { + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, + }, + Status: TxStatusPending, + }, + wantErr: ErrDuplicateInputOutPoint, + wantParamErr: ErrInvalidParam, + }, + { + name: "confirmed create must be published", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Block: testBlock(101), + Status: TxStatusPending, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "valid pending unmined transaction", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + }, + { + name: "valid published confirmed transaction", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Block: testBlock(102), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + }, + { + name: "valid published coinbase facts", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Block: testBlock(103), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateCreateTxParams(tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.ErrorIs(t, err, tc.wantParamErr) + }) + } +} + +// TestNewCreateTxRequest verifies that the shared CreateTx preparation step +// normalizes the request before either backend opens a write transaction. +func TestNewCreateTxRequest(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid request with a non-UTC received timestamp. + params := CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(123, 0).In(time.FixedZone("X", 3600)), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + Label: "note", + } + + // Act: Normalize it through newCreateTxRequest. + req, err := newCreateTxRequest(params) + require.NoError(t, err) + + wantRawTx, err := serializeMsgTx(params.Tx) + require.NoError(t, err) + + // Assert: The prepared request caches the normalized transaction facts. + require.Equal(t, params, req.params) + require.Equal(t, params.Tx.TxHash(), req.txHash) + require.Equal(t, wantRawTx, req.rawTx) + require.Equal(t, time.UTC, req.received.Location()) + require.False(t, req.isCoinbase) +} + +// TestCreateTxWithOpsInsert verifies that the shared CreateTx orchestration +// performs the full insert path in order for one fresh transaction row. +func TestCreateTxWithOpsInsert(t *testing.T) { + t.Parallel() + + // Arrange: Build one prepared CreateTx request and one stub adapter. + req := testCreateTxRequest(t) + ops := &stubCreateTxOps{insertTxID: 11} + + // Act: Run createTxWithOps. + err := createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + + // Assert: The shared flow executes the expected write sequence. + require.Equal(t, + []string{"exists", "prepare-block", "insert", "credits", "inputs"}, + ops.calls, + ) + require.Equal(t, int64(11), ops.creditsTxID) + require.Equal(t, int64(11), ops.inputsTxID) + require.Equal(t, req.txHash, ops.insertReq.txHash) +} + +// TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an +// existing wallet-scoped tx hash to ErrTxAlreadyExists. +func TestCreateTxWithOpsDuplicate(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ops := &stubCreateTxOps{hasExistingResult: true} + + err := createTxWithOps(context.Background(), req, ops) + require.ErrorIs(t, err, ErrTxAlreadyExists) + require.Equal(t, []string{"exists"}, ops.calls) +} + +// testBlock builds a simple block fixture for CreateTx validation tests. +func testBlock(height uint32) *Block { + return &Block{ + Hash: chainhash.Hash{byte(height)}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + // testRegularMsgTx builds a minimal non-coinbase transaction fixture for the // shared TxStore helper tests. func testRegularMsgTx() *wire.MsgTx { tx := wire.NewMsgTx(wire.TxVersion) - tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}}) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}, + }) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + return tx +} + +// testCoinbaseMsgTx builds a minimal coinbase transaction fixture. +func testCoinbaseMsgTx() *wire.MsgTx { + tx := wire.NewMsgTx(wire.TxVersion) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: ^uint32(0)}}) tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) return tx } + +// stubCreateTxOps records how the shared CreateTx helper drives one backend +// adapter while letting each test control the returned transaction IDs. +type stubCreateTxOps struct { + hasExistingResult bool + insertTxID int64 + + calls []string + insertReq createTxRequest + creditsTxID int64 + inputsTxID int64 +} + +var _ createTxOps = (*stubCreateTxOps)(nil) + +// hasExisting records that the shared flow checked whether the tx hash already +// exists and returns the test-controlled result. +func (s *stubCreateTxOps) hasExisting(_ context.Context, + _ createTxRequest) (bool, error) { + + s.calls = append(s.calls, "exists") + + return s.hasExistingResult, nil +} + +// prepareBlock records that the shared flow validated any optional block +// assignment before insert. +func (s *stubCreateTxOps) prepareBlock(_ context.Context, + _ createTxRequest) error { + + s.calls = append(s.calls, "prepare-block") + + return nil +} + +// insert records the request that the shared flow would store as a fresh +// transaction row. +func (s *stubCreateTxOps) insert(_ context.Context, + req createTxRequest) (int64, error) { + + s.calls = append(s.calls, "insert") + s.insertReq = req + + return s.insertTxID, nil +} + +// insertCredits records the transaction ID the shared flow used when +// reconciling wallet-owned outputs. +func (s *stubCreateTxOps) insertCredits(_ context.Context, + _ createTxRequest, txID int64) error { + + s.calls = append(s.calls, "credits") + s.creditsTxID = txID + + return nil +} + +// markInputsSpent records the transaction ID the shared flow used when +// attaching wallet-owned spent inputs. +func (s *stubCreateTxOps) markInputsSpent(_ context.Context, + _ createTxRequest, txID int64) error { + + s.calls = append(s.calls, "inputs") + s.inputsTxID = txID + + return nil +} + +// testCreateTxRequest builds one valid normalized CreateTx request for the +// shared CreateTx orchestration tests. +func testCreateTxRequest(t *testing.T) createTxRequest { + t.Helper() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + return req +} From 3b026f7c0ee3313a6575f843bd4541eec1344125 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:59:26 +0800 Subject: [PATCH 166/695] wallet: add CreateTx Add the postgres and sqlite CreateTx implementations together with CreateTx integration coverage. CreateTx now validates any supplied confirming block during insert so confirmed rows, including coinbase history, can be stored atomically without a later patch step. --- wallet/internal/db/block_pg.go | 35 ++ wallet/internal/db/block_sqlite.go | 32 ++ .../internal/db/itest/tx_utxo_store_test.go | 332 ++++++++++++++++++ .../internal/db/postgres_txstore_createtx.go | 311 ++++++++++++++++ wallet/internal/db/sqlite_txstore_createtx.go | 299 ++++++++++++++++ 5 files changed, 1009 insertions(+) create mode 100644 wallet/internal/db/itest/tx_utxo_store_test.go create mode 100644 wallet/internal/db/postgres_txstore_createtx.go create mode 100644 wallet/internal/db/sqlite_txstore_createtx.go diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index e49647dd50..7fee38f67c 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -1,8 +1,10 @@ package db import ( + "bytes" "context" "database/sql" + "errors" "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -43,3 +45,36 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } + +// requireBlockMatchesPg loads the shared block row for the provided height and +// verifies that its stored metadata matches the supplied block reference. +func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, + block *Block) (int32, error) { + + height, err := uint32ToInt32(block.Height) + if err != nil { + return 0, fmt.Errorf("convert block height: %w", err) + } + + storedBlock, err := qtx.GetBlockByHeight(ctx, height) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("block %d: %w", block.Height, + ErrBlockNotFound) + } + + return 0, fmt.Errorf("get block by height: %w", err) + } + + if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { + return 0, fmt.Errorf("block %d header hash: %w", block.Height, + ErrBlockMismatch) + } + + if storedBlock.BlockTimestamp != block.Timestamp.Unix() { + return 0, fmt.Errorf("block %d timestamp: %w", block.Height, + ErrBlockMismatch) + } + + return height, nil +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index 8fd83c22bf..ffa5413dd7 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -1,8 +1,10 @@ package db import ( + "bytes" "context" "database/sql" + "errors" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -41,3 +43,33 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } + +// requireBlockMatchesSqlite loads the shared block row for the provided height +// and verifies that its stored metadata matches the supplied block reference. +func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + block *Block) (int64, error) { + + height := int64(block.Height) + + storedBlock, err := qtx.GetBlockByHeight(ctx, height) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("block %d: %w", block.Height, + ErrBlockNotFound) + } + + return 0, fmt.Errorf("get block by height: %w", err) + } + + if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { + return 0, fmt.Errorf("block %d header hash: %w", block.Height, + ErrBlockMismatch) + } + + if storedBlock.BlockTimestamp != block.Timestamp.Unix() { + return 0, fmt.Errorf("block %d timestamp: %w", block.Height, + ErrBlockMismatch) + } + + return height, nil +} diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go new file mode 100644 index 0000000000..3e9008b401 --- /dev/null +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -0,0 +1,332 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxStoresWalletCredit verifies that CreateTx stores the transaction +// row and the requested wallet-owned output in one atomic write. +// +// Scenario: +// - One wallet records a new unmined transaction with one wallet-owned +// credited output. +// +// Setup: +// - Create one wallet, one derived account, and one wallet-owned address. +// - Build one transaction that pays that address. +// +// Action: +// - Insert the transaction through CreateTx. +// +// Assertions: +// - The transaction row exists. +// - The credited output exists in the wallet UTXO set. +func TestCreateTxStoresWalletCredit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-tx-credit") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000300, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, tx.TxHash()) + require.True(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: tx.TxHash(), Index: 0, + })) +} + +// TestCreateTxStoresConfirmedCoinbase verifies that CreateTx can record one +// coinbase transaction directly in its confirmed state when the block is +// already known. +// +// Scenario: +// - One wallet learns about one coinbase credit together with its confirming +// block. +// +// Setup: +// - Create one wallet, one derived account, one wallet-owned address, and one +// matching block fixture. +// - Build one coinbase transaction that pays that wallet-owned address. +// +// Action: +// - Insert the coinbase through CreateTx with the block assignment present. +// +// Assertions: +// - The transaction row exists. +// - The wallet-owned coinbase output exists in the current UTXO set. +func TestCreateTxStoresConfirmedCoinbase(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-confirmed-coinbase") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + block := CreateBlockFixture(t, store.Queries(), 210) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710000350, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, coinbaseTx.TxHash()) + require.True(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: coinbaseTx.TxHash(), Index: 0, + })) +} + +// TestCreateTxRejectsInvalidParentWalletOutput verifies that CreateTx rejects a +// child that spends a wallet-owned output whose parent transaction is already +// invalid. +// +// Scenario: +// - One wallet output exists, but its parent transaction has already been +// marked failed. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Rewrite the parent transaction status to failed. +// - Build one child transaction that spends that wallet-owned output. +// +// Action: +// - Insert the child through CreateTx. +// +// Assertions: +// - CreateTx returns ErrTxInputInvalidParent. +// - No child row or child spend edge is persisted. +// - The original parent row remains stored. +func TestCreateTxRejectsInvalidParentWalletOutput(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalid-parent") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 50000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000400, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + setTxStatus(t, store, walletID, parentTx.TxHash(), db.TxStatusFailed) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 49000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710000410, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxInputInvalidParent) + require.Empty(t, childSpendingTxIDs(t, store, walletID, parentTx.TxHash())) + _, ok := txIDByHash(t, store, walletID, childTx.TxHash()) + require.False(t, ok) + _, ok = txIDByHash(t, store, walletID, parentTx.TxHash()) + require.True(t, ok) +} + +// TestCreateTxRejectsSecondPendingSpend verifies that CreateTx rejects a second +// pending transaction that spends the same wallet-owned output. +// +// Scenario: +// - One wallet-owned output already has one pending child spender. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Insert one first child transaction that spends it. +// - Build one second child that spends the same outpoint. +// +// Action: +// - Insert the second child through CreateTx. +// +// Assertions: +// - CreateTx returns ErrTxInputConflict. +// - Only the first child remains recorded as the spender. +// - The second child row is not inserted. +func TestCreateTxRejectsSecondPendingSpend(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-second-spend-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + firstChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: firstChild, + Received: time.Unix(1710000510, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + secondChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: secondChild, + Received: time.Unix(1710000520, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxInputConflict) + + childIDs := childSpendingTxIDs(t, store, walletID, parentTx.TxHash()) + require.Len(t, childIDs, 1) + + _, ok := txIDByHash(t, store, walletID, firstChild.TxHash()) + require.True(t, ok) + _, ok = txIDByHash(t, store, walletID, secondChild.TxHash()) + require.False(t, ok) +} + +// TestCreateTxRejectsDuplicateTx verifies that CreateTx inserts one wallet- +// scoped transaction row only once. +// +// Scenario: +// - One wallet transaction hash is already present in the store. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Attempt to insert the same transaction hash again. +// +// Assertions: +// - CreateTx returns ErrTxAlreadyExists. +// - The original row remains stored once. +func TestCreateTxRejectsDuplicateTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-tx-duplicate") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000580, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000590, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) + + _, ok := txIDByHash(t, store, walletID, tx.TxHash()) + require.True(t, ok) +} + +// newCoinbaseTx builds a simple coinbase fixture transaction. +func newCoinbaseTx(pkScript []byte) *wire.MsgTx { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: ^uint32(0)}}) + tx.AddTxOut(&wire.TxOut{Value: 5000, PkScript: pkScript}) + + return tx +} + +// newRegularTx builds a simple fixture transaction with the provided inputs and +// outputs. +func newRegularTx(inputs []wire.OutPoint, outputs []*wire.TxOut) *wire.MsgTx { + tx := wire.NewMsgTx(2) + + for _, prevOut := range inputs { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: prevOut}) + } + + for _, txOut := range outputs { + tx.AddTxOut(txOut) + } + + return tx +} + +// randomOutPoint returns one fixture outpoint backed by a random hash. +func randomOutPoint() wire.OutPoint { + return wire.OutPoint{Hash: RandomHash(), Index: 0} +} diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go new file mode 100644 index 0000000000..370f61d14c --- /dev/null +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -0,0 +1,311 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// CreateTx atomically records a wallet-scoped transaction row, its +// wallet-owned credits, and any spend edges created by its inputs. +// +// The full write runs inside ExecuteTx so the transaction row, created UTXOs, +// and spent-parent markers are either committed together or not at all. +// Received timestamps are normalized to UTC before insert. CreateTx is +// insert-only and returns ErrTxAlreadyExists if the wallet already stores the +// tx hash. +func (s *PostgresStore) CreateTx(ctx context.Context, + params CreateTxParams) error { + + req, err := newCreateTxRequest(params) + if err != nil { + return err + } + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return createTxWithOps(ctx, req, &pgCreateTxOps{qtx: qtx}) + }) +} + +// pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. +type pgCreateTxOps struct { + qtx *sqlcpg.Queries + + blockHeight sql.NullInt32 +} + +var _ createTxOps = (*pgCreateTxOps)(nil) + +// hasExisting reports whether the wallet already stores the requested tx hash. +func (o *pgCreateTxOps) hasExisting(ctx context.Context, + req createTxRequest) (bool, error) { + + _, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("get tx metadata: %w", err) +} + +// prepareBlock validates the optional confirming block and caches the postgres +// block-height value that the later insert query will store. +func (o *pgCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { + + o.blockHeight = sql.NullInt32{} + + if req.params.Block == nil { + return nil + } + + height, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + if err != nil { + return err + } + + o.blockHeight = sql.NullInt32{Int32: height, Valid: true} + + return nil +} + +// insert stores one new postgres transaction row for CreateTx. +func (o *pgCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + RawTx: req.rawTx, + BlockHeight: o.blockHeight, + TxStatus: int16(req.params.Status), + ReceivedTime: req.received, + IsCoinbase: req.isCoinbase, + TxLabel: req.params.Label, + }) + if err != nil { + return 0, fmt.Errorf("insert tx row: %w", err) + } + + return txID, nil +} + +// insertCredits stores any wallet-owned outputs created by the transaction. +func (o *pgCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { + + return insertCreditsPg(ctx, o.qtx, req.params, txID) +} + +// markInputsSpent records wallet-owned inputs spent by the transaction. +func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { + + return markInputsSpentPg(ctx, o.qtx, req.params, txID) +} + +// insertCreditsPg inserts one wallet-owned UTXO row for each credited output of +// the transaction being stored. +func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, + params CreateTxParams, txID int64) error { + + for index := range params.Credits { + creditExists, err := creditExistsPg( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + continue + } + + pkScript := params.Tx.TxOut[index].PkScript + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: pkScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + outputIndex, err := uint32ToInt32(index) + if err != nil { + return fmt.Errorf("convert credit index %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlcpg.InsertUtxoParams{ + WalletID: int64(params.WalletID), + TxID: txID, + OutputIndex: outputIndex, + Amount: params.Tx.TxOut[index].Value, + AddressID: addrRow.ID, + }) + if err != nil { + return fmt.Errorf("insert credit output %d: %w", index, err) + } + } + + return nil +} + +// creditExistsPg reports whether the wallet already has a UTXO row for the +// given credited output, even if that output is now spent by a child tx. +func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + + convertedIndex, err := uint32ToInt32(outputIndex) + if err != nil { + return false, fmt.Errorf("convert credit index %d: %w", outputIndex, + err) + } + + _, err = qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: convertedIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, + err) + } + + return true, nil +} + +// markInputsSpentPg attaches wallet-owned outpoints spent by the stored +// transaction to its row ID and input indexes. +// +// If another wallet transaction already owns the spend edge for a +// wallet-controlled input, the create path fails with ErrTxInputConflict +// instead of silently storing a second spender. Inputs that reference a +// wallet-owned output whose parent transaction is already invalid fail with +// ErrTxInputInvalidParent. +func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, + params CreateTxParams, txID int64) error { + + if blockchain.IsCoinBaseTx(params.Tx) { + return nil + } + + for inputIndex, txIn := range params.Tx.TxIn { + outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + if err != nil { + return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, + err) + } + + spentInputIndex, err := int64ToInt32(int64(inputIndex)) + if err != nil { + return fmt.Errorf("convert input index %d: %w", inputIndex, err) + } + + rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlcpg.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: outputIndex, + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + SpentInputIndex: sql.NullInt32{Int32: spentInputIndex, Valid: true}, + }) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + + if rowsAffected == 0 { + err = ensureSpendConflictPg( + ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, + outputIndex, txID, + ) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + } + } + + return nil +} + +// ensureSpendConflictPg reports ErrTxInputConflict when the referenced outpoint +// is wallet-owned, still eligible for spending, and already attached to another +// transaction. If the wallet owns the parent output but that parent is already +// invalid, the helper returns ErrTxInputInvalidParent instead. +func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex int32, + txID int64) error { + + spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ensureWalletParentValidPg( + ctx, qtx, walletID, txHash, outputIndex, + ) + } + + return fmt.Errorf("check spend conflict: %w", err) + } + + if spendByTxID.Valid && spendByTxID.Int64 != txID { + return ErrTxInputConflict + } + + return nil +} + +// ensureWalletParentValidPg reports ErrTxInputInvalidParent when the wallet +// owns the referenced outpoint but its parent transaction is already invalid. +func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex int32) error { + + hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( + ctx, sqlcpg.HasInvalidWalletUtxoByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + return fmt.Errorf("check invalid wallet parent: %w", err) + } + + if hasInvalid { + return ErrTxInputInvalidParent + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go new file mode 100644 index 0000000000..18d6013b35 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -0,0 +1,299 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/chainhash/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// CreateTx atomically records a wallet-scoped transaction row, its wallet-owned +// credits, and any spend edges created by its inputs. +// +// The full write runs inside ExecuteTx so the transaction row, created UTXOs, +// and spent-parent markers are either committed together or not at all. +// Received timestamps are normalized to UTC before insert. CreateTx is +// insert-only and returns ErrTxAlreadyExists if the wallet already stores the +// tx hash. +func (s *SqliteStore) CreateTx(ctx context.Context, + params CreateTxParams) error { + + req, err := newCreateTxRequest(params) + if err != nil { + return err + } + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return createTxWithOps(ctx, req, &sqliteCreateTxOps{qtx: qtx}) + }) +} + +// sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. +type sqliteCreateTxOps struct { + qtx *sqlcsqlite.Queries + + blockHeight sql.NullInt64 +} + +var _ createTxOps = (*sqliteCreateTxOps)(nil) + +// hasExisting reports whether the wallet already stores the requested tx hash. +func (o *sqliteCreateTxOps) hasExisting(ctx context.Context, + req createTxRequest) (bool, error) { + + _, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + + // Exit early if there is no error. + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("get tx metadata: %w", err) +} + +// prepareBlock validates the optional confirming block and caches the sqlite +// block-height value that the later insert query will store. +func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { + + o.blockHeight = sql.NullInt64{} + + if req.params.Block == nil { + return nil + } + + height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + if err != nil { + return err + } + + o.blockHeight = sql.NullInt64{Int64: height, Valid: true} + + return nil +} + +// insert stores one new sqlite transaction row for CreateTx. +func (o *sqliteCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + txID, err := o.qtx.InsertTransaction( + ctx, + sqlcsqlite.InsertTransactionParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + RawTx: req.rawTx, + BlockHeight: o.blockHeight, + TxStatus: int64(req.params.Status), + ReceivedTime: req.received, + IsCoinbase: req.isCoinbase, + TxLabel: req.params.Label, + }, + ) + if err != nil { + return 0, fmt.Errorf("insert tx row: %w", err) + } + + return txID, nil +} + +// insertCredits stores any wallet-owned outputs created by the transaction. +func (o *sqliteCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { + + return insertCreditsSqlite(ctx, o.qtx, req.params, txID) +} + +// markInputsSpent records wallet-owned inputs spent by the transaction. +func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { + + return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) +} + +// insertCreditsSqlite inserts one wallet-owned UTXO row for each credited +// output of the transaction being stored. +func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + params CreateTxParams, txID int64) error { + + for index := range params.Credits { + creditExists, err := creditExistsSqlite( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + continue + } + + pkScript := params.Tx.TxOut[index].PkScript + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlcsqlite.GetAddressByScriptPubKeyParams{ + ScriptPubKey: pkScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlcsqlite.InsertUtxoParams{ + WalletID: int64(params.WalletID), + TxID: txID, + OutputIndex: int64(index), + Amount: params.Tx.TxOut[index].Value, + AddressID: addrRow.ID, + }) + if err != nil { + return fmt.Errorf("insert credit output %d: %w", index, err) + } + } + + return nil +} + +// creditExistsSqlite reports whether the wallet already has a UTXO row for the +// given credited output, even if that output is now spent by a child tx. +func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + + _, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: int64(outputIndex), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, + err) + } + + return true, nil +} + +// markInputsSpentSqlite attaches wallet-owned outpoints spent by the stored +// transaction to its row ID and input indexes. +// +// If another wallet transaction already owns the spend edge for a +// wallet-controlled input, the create path fails with ErrTxInputConflict +// instead of silently storing a second spender. Inputs that reference a +// wallet-owned output whose parent transaction is already invalid fail with +// ErrTxInputInvalidParent. +func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + params CreateTxParams, txID int64) error { + + if blockchain.IsCoinBaseTx(params.Tx) { + return nil + } + + for inputIndex, txIn := range params.Tx.TxIn { + spentInputIndex := sql.NullInt64{Int64: int64(inputIndex), Valid: true} + + rowsAffected, err := qtx.MarkUtxoSpent(ctx, + sqlcsqlite.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: int64(txIn.PreviousOutPoint.Index), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + SpentInputIndex: spentInputIndex, + }) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + + if rowsAffected == 0 { + err = ensureSpendConflictSqlite( + ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, + int64(txIn.PreviousOutPoint.Index), txID, + ) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + } + } + + return nil +} + +// ensureSpendConflictSqlite reports ErrTxInputConflict when the referenced +// outpoint is wallet-owned, still eligible for spending, and already attached +// to another transaction. If the wallet owns the parent output but that parent +// is already invalid, the helper returns ErrTxInputInvalidParent instead. +func ensureSpendConflictSqlite(ctx context.Context, + qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + outputIndex int64, txID int64) error { + + spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ensureWalletParentValidSqlite( + ctx, qtx, walletID, txHash, outputIndex, + ) + } + + return fmt.Errorf("check spend conflict: %w", err) + } + + if spendByTxID.Valid && spendByTxID.Int64 != txID { + return ErrTxInputConflict + } + + return nil +} + +// ensureWalletParentValidSqlite reports ErrTxInputInvalidParent when the +// wallet owns the referenced outpoint but its parent transaction is already +// invalid. +func ensureWalletParentValidSqlite(ctx context.Context, + qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + outputIndex int64) error { + + hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( + ctx, sqlcsqlite.HasInvalidWalletUtxoByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + return fmt.Errorf("check invalid wallet parent: %w", err) + } + + if hasInvalid { + return ErrTxInputInvalidParent + } + + return nil +} From d6ff4b16724a3d6fd69d339dd7ef1b7d2556563a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:01:09 +0800 Subject: [PATCH 167/695] wallet: add GetTx Add the postgres and sqlite GetTx implementations together with GetTx integration coverage. Keeping the row-to-domain rebuild logic with the method commit makes the read path easy to review without mixing in unrelated shared ops work. --- .../internal/db/itest/tx_utxo_store_test.go | 72 +++++++++++++++++++ wallet/internal/db/postgres_txstore_gettx.go | 61 ++++++++++++++++ wallet/internal/db/sqlite_txstore_gettx.go | 61 ++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_gettx.go create mode 100644 wallet/internal/db/sqlite_txstore_gettx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 3e9008b401..ccd76b49a6 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -301,6 +301,78 @@ func TestCreateTxRejectsDuplicateTx(t *testing.T) { require.True(t, ok) } +// TestGetTxReturnsStoredPendingTx verifies that GetTx rebuilds the public +// transaction view for one stored unmined row. +// +// Scenario: +// - One pending wallet transaction has already been inserted. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Retrieve the transaction through GetTx. +// +// Assertions: +// - GetTx returns the stored hash, status, label, and nil block metadata. +func TestGetTxReturnsStoredPendingTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-tx") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000600, 0), + Status: db.TxStatusPending, + Label: "pending-note", + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, tx.TxHash(), info.Hash) + require.Equal(t, db.TxStatusPending, info.Status) + require.Equal(t, "pending-note", info.Label) + require.Nil(t, info.Block) +} + +// TestGetTxNotFound verifies that GetTx returns ErrTxNotFound when the wallet +// has no matching transaction row. +// +// Scenario: +// - One wallet has no stored transaction for the requested hash. +// +// Setup: +// - Create one wallet and choose one random transaction hash. +// +// Action: +// - Query the missing hash through GetTx. +// +// Assertions: +// - GetTx returns ErrTxNotFound. +func TestGetTxNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-tx-missing") + + _, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: RandomHash(), + }) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/postgres_txstore_gettx.go new file mode 100644 index 0000000000..80f0c56955 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_gettx.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetTx retrieves one wallet-scoped transaction snapshot by hash. +// +// The returned TxInfo is rebuilt from normalized SQL columns; missing rows map +// to ErrTxNotFound for the requested wallet/hash pair. +func (s *PostgresStore) GetTx(ctx context.Context, + query GetTxQuery) (*TxInfo, error) { + + row, err := s.queries.GetTransactionByHash( + ctx, sqlcpg.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + } + + return nil, fmt.Errorf("get tx: %w", err) + } + + return txInfoFromPgRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) +} + +// txInfoFromPgRow converts one normalized postgres query row into the public +// TxInfo shape. +func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, + blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, + status int64, label string) (*TxInfo, error) { + + var ( + block *Block + err error + ) + + // Unmined rows legitimately have no block metadata, so only build the Block + // shape when the row still carries a valid height. + if blockHeight.Valid { + block, err = buildPgBlock(blockHeight, blockHash, blockTimestamp) + if err != nil { + return nil, err + } + } + + return buildTxInfo(hash, rawTx, received, block, status, label) +} diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite_txstore_gettx.go new file mode 100644 index 0000000000..c276800380 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_gettx.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetTx retrieves one wallet-scoped transaction snapshot by hash. +// +// The returned TxInfo is rebuilt from normalized SQL columns; missing rows map +// to ErrTxNotFound for the requested wallet/hash pair. +func (s *SqliteStore) GetTx(ctx context.Context, + query GetTxQuery) (*TxInfo, error) { + + row, err := s.queries.GetTransactionByHash( + ctx, sqlcsqlite.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + } + + return nil, fmt.Errorf("get tx: %w", err) + } + + return txInfoFromSqliteRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) +} + +// txInfoFromSqliteRow converts one normalized sqlite query row into the public +// TxInfo shape. +func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, + blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, + status int64, label string) (*TxInfo, error) { + + var ( + block *Block + err error + ) + + // Unmined rows legitimately have no block metadata, so only build the Block + // shape when the row still carries a valid height. + if blockHeight.Valid { + block, err = buildSqliteBlock(blockHeight, blockHash, blockTimestamp) + if err != nil { + return nil, err + } + } + + return buildTxInfo(hash, rawTx, received, block, status, label) +} From c99505fd75567d4154a0374c9ec72ee97351f502 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:02:21 +0800 Subject: [PATCH 168/695] wallet: add UpdateTx ops Add the shared UpdateTx validation and patch orchestration used by both SQL backends. Landing the common patch flow first keeps the later backend-specific state and label writes isolated in the UpdateTx method commit. --- wallet/internal/db/tx_store_common.go | 131 +++++++++++++++++++++ wallet/internal/db/tx_store_common_test.go | 99 ++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 7300d82ead..ab9f00cc47 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -329,3 +329,134 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } + +// validateUpdateTxParams checks that UpdateTx received at least one mutable +// field and that any requested state transition satisfies the transaction table +// invariants. +func validateUpdateTxParams(params UpdateTxParams, isCoinbase bool) error { + if params.Label == nil && params.State == nil { + return fmt.Errorf("%w: UpdateTx requires at least one field", + ErrInvalidParam) + } + + if params.State != nil { + return validateUpdateTxState(*params.State, isCoinbase) + } + + return nil +} + +// validateUpdateTxState checks the block/status combinations UpdateTx may store +// on an existing row. +func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { + _, err := parseTxStatus(int64(state.Status)) + if err != nil { + return fmt.Errorf("%w: status %d is not supported: %w", + ErrInvalidParam, state.Status, ErrInvalidStatus) + } + + // Only disconnected coinbase rows become orphaned. Ordinary + // transactions use the replaced/failed states instead, so UpdateTx + // must reject orphaned transitions for non-coinbase history. + if !isCoinbase && state.Status == TxStatusOrphaned { + return fmt.Errorf("%w: non-coinbase txns cannot be orphaned: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // Any row with a confirming block represents mined history, and mined + // wallet history is always published from the wallet's point of view. + if state.Block != nil && state.Status != TxStatusPublished { + return fmt.Errorf("%w: confirmed txns must be published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // A unmined coinbase row only appears after rollback disconnects its block, + // at which point the row must be marked orphaned rather than treated as an + // active unmined transaction. + if isCoinbase && state.Block == nil && state.Status != TxStatusOrphaned { + return fmt.Errorf("%w: unmined coinbase txns must be orphaned: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil +} + +// updateTxOps is the minimal backend adapter the shared UpdateTx workflow +// needs. +// +// The shared UpdateTx algorithm is intentionally ordered: +// - load the target row metadata first +// - validate the requested label/state patch against that metadata +// - prepare any backend-specific block/status params next +// - apply the label patch if requested +// - apply the state patch last +// +// Keeping that sequence documented here matters because UpdateTx is +// deliberately row-local. The backend adapters only supply query wiring. The +// shared helper owns the mutation ordering and the invariants that keep +// block/status patches from accidentally turning into graph-level +// reconciliation. +type updateTxOps interface { + // loadIsCoinbase returns whether the existing row is coinbase history + // so the shared validation can enforce orphaning rules correctly. + loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) + + // prepareState validates and caches any backend-specific block/status + // params needed for the later row update. + prepareState(ctx context.Context, state UpdateTxState) error + + // updateLabel applies one user-visible label patch. + updateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, + label string) error + + // updateState applies one block/status patch after prepareState succeeds. + updateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, + state UpdateTxState) error +} + +// updateTxWithOps runs the shared UpdateTx patch workflow inside one backend- +// specific SQL transaction. +// +// The helper validates the existing row first, prepares any requested state +// patch next, and then applies the label patch and state patch in that order. +// That keeps block validation and row mutation inside one transaction while +// still allowing callers to update either field independently. +func updateTxWithOps(ctx context.Context, params UpdateTxParams, + ops updateTxOps) error { + + isCoinbase, err := ops.loadIsCoinbase( + ctx, params.WalletID, params.Txid, + ) + if err != nil { + return fmt.Errorf("load update tx target: %w", err) + } + + err = validateUpdateTxParams(params, isCoinbase) + if err != nil { + return err + } + + if params.State != nil { + err = ops.prepareState(ctx, *params.State) + if err != nil { + return fmt.Errorf("prepare tx state update: %w", err) + } + } + + if params.Label != nil { + err = ops.updateLabel(ctx, params.WalletID, params.Txid, *params.Label) + if err != nil { + return fmt.Errorf("update tx label: %w", err) + } + } + + if params.State != nil { + err = ops.updateState(ctx, params.WalletID, params.Txid, *params.State) + if err != nil { + return fmt.Errorf("update tx state: %w", err) + } + } + + return nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 4ba9ee348a..27d01538c5 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -462,3 +462,102 @@ func testCreateTxRequest(t *testing.T) createTxRequest { return req } + +// TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow +// can apply both a label patch and a state patch in one atomic sequence. +func TestUpdateTxWithOpsLabelAndState(t *testing.T) { + t.Parallel() + + // Arrange: Build one request with both a label patch and a state patch. + label := "note" + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + Label: &label, + State: &UpdateTxState{ + Status: TxStatusPublished, + }, + } + ops := &stubUpdateTxOps{isCoinbase: false} + + // Act: Run updateTxWithOps against a stub backend adapter. + err := updateTxWithOps(context.Background(), params, ops) + require.NoError(t, err) + + // Assert: The shared flow loads, prepares, and applies both patches. + require.Equal(t, + []string{"load", "prepare-state", "label", "state"}, + ops.calls, + ) + require.Equal(t, label, ops.updatedLabel) + require.Equal(t, TxStatusPublished, ops.updatedState.Status) +} + +// TestUpdateTxWithOpsEmptyPatch verifies that the shared UpdateTx helper +// rejects requests that do not ask to mutate any field. +func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { + t.Parallel() + + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + } + ops := &stubUpdateTxOps{isCoinbase: false} + + err := updateTxWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.Equal(t, []string{"load"}, ops.calls) +} + +// stubUpdateTxOps records how the shared UpdateTx helper drives one backend +// adapter while letting tests control the loaded metadata. +type stubUpdateTxOps struct { + isCoinbase bool + calls []string + updatedLabel string + updatedState UpdateTxState +} + +var _ updateTxOps = (*stubUpdateTxOps)(nil) + +// loadIsCoinbase records that the shared flow loaded the existing transaction +// row metadata. +func (s *stubUpdateTxOps) loadIsCoinbase(_ context.Context, _ uint32, + _ chainhash.Hash) (bool, error) { + + s.calls = append(s.calls, "load") + + return s.isCoinbase, nil +} + +// prepareState records that the shared flow validated and prepared one state +// patch before applying it. +func (s *stubUpdateTxOps) prepareState(_ context.Context, + _ UpdateTxState) error { + + s.calls = append(s.calls, "prepare-state") + + return nil +} + +// updateLabel records the label value the shared flow asked the backend to +// write. +func (s *stubUpdateTxOps) updateLabel(_ context.Context, _ uint32, + _ chainhash.Hash, label string) error { + + s.calls = append(s.calls, "label") + s.updatedLabel = label + + return nil +} + +// updateState records the state patch the shared flow asked the backend to +// write. +func (s *stubUpdateTxOps) updateState(_ context.Context, _ uint32, + _ chainhash.Hash, state UpdateTxState) error { + + s.calls = append(s.calls, "state") + s.updatedState = state + + return nil +} From 987bbdac6bc3e4ae2cb8277b8a83075dce09e24f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:03:22 +0800 Subject: [PATCH 169/695] wallet: add UpdateTx Add the postgres and sqlite UpdateTx implementations together with UpdateTx integration coverage. This keeps the backend-specific state writes and label updates in one place now that block validation helpers are already shared with CreateTx. --- .../internal/db/itest/tx_utxo_store_test.go | 301 ++++++++++++++++++ .../internal/db/postgres_txstore_updatetx.go | 134 ++++++++ wallet/internal/db/sqlite_txstore_updatetx.go | 134 ++++++++ 3 files changed, 569 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_updatetx.go create mode 100644 wallet/internal/db/sqlite_txstore_updatetx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index ccd76b49a6..ee361ef26e 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -373,6 +373,307 @@ func TestGetTxNotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrTxNotFound) } +// TestUpdateTxRequiresExistingConfirmedBlock verifies that UpdateTx rejects a +// state patch whose referenced block height is missing from the shared blocks +// table. +// +// Scenario: +// - One stored pending transaction is later patched with a missing block. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Build one block reference without inserting that block row. +// +// Action: +// - Apply the confirmation patch through UpdateTx. +// +// Assertions: +// - UpdateTx returns ErrBlockNotFound. +// - The transaction remains unconfirmed. +func TestUpdateTxRequiresExistingConfirmedBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirmed-tx-missing-block") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + block := db.Block{ + Hash: RandomHash(), + Height: 240, + Timestamp: time.Unix(1710000560, 0), + } + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000570, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &block, + Status: db.TxStatusPublished, + }, + }) + require.ErrorIs(t, err, db.ErrBlockNotFound) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, info.Block) +} + +// TestUpdateTxRejectsMismatchedConfirmedBlock verifies that UpdateTx rejects a +// state patch when the supplied block metadata does not match the stored block +// row for that height. +// +// Scenario: +// - One stored pending transaction is later patched with mismatched block +// metadata for an existing height. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Insert the real block row for the target height. +// - Build a second block reference with the same height but different hash. +// +// Action: +// - Apply the mismatched confirmation patch through UpdateTx. +// +// Assertions: +// - UpdateTx returns ErrBlockMismatch. +// - The existing transaction row remains unconfirmed and pending. +func TestUpdateTxRejectsMismatchedConfirmedBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-block-mismatch") + queries := store.Queries() + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000550, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + block := CreateBlockFixture(t, queries, 240) + mismatchBlock := block + mismatchBlock.Hash = RandomHash() + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &mismatchBlock, + Status: db.TxStatusPublished, + }, + }) + require.ErrorIs(t, err, db.ErrBlockMismatch) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, info.Block) + require.Equal(t, db.TxStatusPending, info.Status) +} + +// TestUpdateTxUpdatesStoredLabel verifies that UpdateTx can patch the stored +// user-visible label without mutating chain-state metadata. +// +// Scenario: +// - One pending wallet transaction already exists with an old label. +// +// Setup: +// - Create one wallet and insert one pending transaction row with a label. +// +// Action: +// - Patch only the label through UpdateTx. +// +// Assertions: +// - The stored label changes. +// - The transaction stays pending and unconfirmed. +func TestUpdateTxUpdatesStoredLabel(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-label") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000700, 0), + Status: db.TxStatusPending, + Label: "old-label", + }) + require.NoError(t, err) + + label := "new-label" + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + Label: &label, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, "new-label", info.Label) + require.Equal(t, db.TxStatusPending, info.Status) +} + +// TestUpdateTxConfirmsStoredPendingTx verifies that UpdateTx can attach a +// confirming block to an already-stored unmined row. +// +// Scenario: +// - One pending wallet transaction is later observed in a block. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Insert one matching block row. +// +// Action: +// - Apply a published state patch with that block through UpdateTx. +// +// Assertions: +// - The transaction now carries the block metadata. +// - The status becomes published. +func TestUpdateTxConfirmsStoredPendingTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-confirm") + queries := store.Queries() + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000710, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + block := CreateBlockFixture(t, queries, 220) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &block, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, info.Block) + require.Equal(t, block.Height, info.Block.Height) + require.Equal(t, block.Hash, info.Block.Hash) + require.Equal(t, db.TxStatusPublished, info.Status) +} + +// TestUpdateTxNotFound verifies that UpdateTx returns ErrTxNotFound when the +// wallet has no matching transaction row. +// +// Scenario: +// - One wallet has no stored transaction for the requested hash. +// +// Setup: +// - Create one wallet and one label patch. +// +// Action: +// - Apply the patch to a random missing tx hash. +// +// Assertions: +// - UpdateTx returns ErrTxNotFound. +func TestUpdateTxNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-label-missing") + + label := "new-label" + err := store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: RandomHash(), + Label: &label, + }) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestUpdateTxRejectsEmptyPatch verifies that UpdateTx rejects a request that +// does not ask to mutate any transaction field. +// +// Scenario: +// - One wallet transaction exists, but the caller provides no label or state +// mutation. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Call UpdateTx with an empty patch. +// +// Assertions: +// - UpdateTx returns ErrInvalidParam. +func TestUpdateTxRejectsEmptyPatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-empty-patch") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000720, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.ErrorIs(t, err, db.ErrInvalidParam) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/postgres_txstore_updatetx.go new file mode 100644 index 0000000000..dbbe6ca180 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_updatetx.go @@ -0,0 +1,134 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// UpdateTx patches the mutable metadata for one wallet-scoped transaction. +// +// UpdateTx may edit the user-visible label, the block/status view, or both in +// one SQL transaction. Immutable transaction facts such as raw_tx, credits, and +// spent-input edges stay owned by CreateTx and the internal rollback/delete +// flows. +func (s *PostgresStore) UpdateTx(ctx context.Context, + params UpdateTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return updateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + }) +} + +// pgUpdateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. +type pgUpdateTxOps struct { + // qtx is the transaction-scoped postgres query set used by UpdateTx. + qtx *sqlcpg.Queries + + // blockHeight caches the validated postgres block-height wrapper prepared + // for the later state update query. + blockHeight sql.NullInt32 + + // status caches the postgres status code prepared for the later state + // update query. + status int16 +} + +var _ updateTxOps = (*pgUpdateTxOps)(nil) + +// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// validate one patch. +func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) { + + meta, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return false, fmt.Errorf("get tx metadata: %w", err) + } + + return meta.IsCoinbase, nil +} + +// prepareState validates any referenced confirming block and captures the +// postgres-specific state params for the later row update. +func (o *pgUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { + + blockHeight := sql.NullInt32{} + + if state.Block != nil { + height, err := requireBlockMatchesPg(ctx, o.qtx, state.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + blockHeight = sql.NullInt32{Int32: height, Valid: true} + } + + o.blockHeight = blockHeight + o.status = int16(state.Status) + + return nil +} + +// updateState writes one block/status patch after prepareState has validated +// any referenced block metadata. +func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, _ UpdateTxState) error { + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, + sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: o.blockHeight, + Status: o.status, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} + +// updateLabel writes one user-visible label change. +func (o *pgUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { + + rows, err := o.qtx.UpdateTransactionLabelByHash( + ctx, + sqlcpg.UpdateTransactionLabelByHashParams{ + Label: label, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx label query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite_txstore_updatetx.go new file mode 100644 index 0000000000..71a9e397d7 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_updatetx.go @@ -0,0 +1,134 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// UpdateTx patches the mutable metadata for one wallet-scoped transaction. +// +// UpdateTx may edit the user-visible label, the block/status view, or both in +// one SQL transaction. Immutable transaction facts such as raw_tx, credits, and +// spent-input edges stay owned by CreateTx and the internal rollback/delete +// flows. +func (s *SqliteStore) UpdateTx(ctx context.Context, + params UpdateTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return updateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + }) +} + +// sqliteUpdateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. +type sqliteUpdateTxOps struct { + // qtx is the transaction-scoped sqlite query set used by UpdateTx. + qtx *sqlcsqlite.Queries + + // blockHeight caches the validated sqlite block-height wrapper prepared for + // the later state update query. + blockHeight sql.NullInt64 + + // status caches the sqlite status code prepared for the later state update + // query. + status int64 +} + +var _ updateTxOps = (*sqliteUpdateTxOps)(nil) + +// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// validate one patch. +func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (bool, error) { + + meta, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return false, fmt.Errorf("get tx metadata: %w", err) + } + + return meta.IsCoinbase, nil +} + +// prepareState validates any referenced confirming block and captures the +// sqlite-specific state params for the later row update. +func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { + + blockHeight := sql.NullInt64{} + + if state.Block != nil { + height, err := requireBlockMatchesSqlite(ctx, o.qtx, state.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + blockHeight = sql.NullInt64{Int64: height, Valid: true} + } + + o.blockHeight = blockHeight + o.status = int64(state.Status) + + return nil +} + +// updateLabel writes one user-visible label change. +func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { + + rows, err := o.qtx.UpdateTransactionLabelByHash( + ctx, + sqlcsqlite.UpdateTransactionLabelByHashParams{ + Label: label, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx label query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} + +// updateState writes one block/status patch after prepareState has validated +// any referenced block metadata. +func (o *sqliteUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, _ UpdateTxState) error { + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, + sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: o.blockHeight, + Status: o.status, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} From 3b7553e67f13a0b99ae142f6c582f80de55dc19b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:04:15 +0800 Subject: [PATCH 170/695] wallet: add ListTxns Add the postgres and sqlite ListTxns implementations together with ListTxns integration coverage. This keeps the confirmed-range and unmined-only read paths grouped with the tests that exercise their wallet-visible results. --- .../internal/db/itest/tx_utxo_store_test.go | 185 ++++++++++++++++++ .../internal/db/postgres_txstore_listtxns.go | 104 ++++++++++ wallet/internal/db/sqlite_txstore_listtxns.go | 94 +++++++++ 3 files changed, 383 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_listtxns.go create mode 100644 wallet/internal/db/sqlite_txstore_listtxns.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index ee361ef26e..0f7c414411 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -674,6 +674,191 @@ func TestUpdateTxRejectsEmptyPatch(t *testing.T) { require.ErrorIs(t, err, db.ErrInvalidParam) } +// TestListTxnsReturnsRowsWithoutBlock verifies that the no-confirming-block +// query path excludes confirmed rows while still surfacing retained invalid +// history that no longer has block metadata. +// +// Scenario: +// - One wallet has confirmed history, active unmined history, and retained +// invalid history without blocks. +// +// Setup: +// - Insert one confirmed transaction, one pending transaction, and one failed +// transaction whose block is nil. +// +// Action: +// - Query ListTxns with UnminedOnly set. +// +// Assertions: +// - Only unmined rows are returned. +// - Both the active pending row and the failed history row are present. +func TestListTxnsReturnsRowsWithoutBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-txns-without-block") + queries := store.Queries() + + confirmedBlock := CreateBlockFixture(t, queries, 200) + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: []byte{0x51}}}, + ) + unminedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8000, PkScript: []byte{0x52}}}, + ) + failedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8100, PkScript: []byte{0x53}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000800, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + State: &db.UpdateTxState{ + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: unminedTx, + Received: time.Unix(1710000810, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: failedTx, + Received: time.Unix(1710000815, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + setTxStatus(t, store, walletID, failedTx.TxHash(), db.TxStatusFailed) + + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }) + require.NoError(t, err) + require.Len(t, infos, 2) + + statusesByHash := make(map[chainhash.Hash]db.TxStatus, len(infos)) + for _, info := range infos { + require.Nil(t, info.Block) + statusesByHash[info.Hash] = info.Status + } + + require.Equal(t, db.TxStatusPending, statusesByHash[unminedTx.TxHash()]) + require.Equal(t, db.TxStatusFailed, statusesByHash[failedTx.TxHash()]) +} + +// TestListTxnsReturnsConfirmedTxsByHeightRange verifies that the +// confirmed-range query path excludes unmined rows and respects the height +// bounds. +// +// Scenario: +// - One wallet has confirmed transactions at multiple heights plus one +// unmined row. +// +// Setup: +// - Insert two confirmed transactions at different heights and one pending +// transaction without a block. +// +// Action: +// - Query ListTxns for one exact confirmed height range. +// +// Assertions: +// - Only the matching confirmed transaction is returned. +// - The unmined row is excluded. +func TestListTxnsReturnsConfirmedTxsByHeightRange(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-txns-confirmed") + queries := store.Queries() + + blockOne := CreateBlockFixture(t, queries, 210) + blockTwo := CreateBlockFixture(t, queries, 211) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 9000, PkScript: []byte{0x51}}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 10000, PkScript: []byte{0x52}}}, + ) + unminedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 11000, PkScript: []byte{0x53}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710000900, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: txOne.TxHash(), + State: &db.UpdateTxState{ + Block: &blockOne, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710000910, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: txTwo.TxHash(), + State: &db.UpdateTxState{ + Block: &blockTwo, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: unminedTx, + Received: time.Unix(1710000920, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 211, + EndHeight: 211, + }) + require.NoError(t, err) + require.Len(t, infos, 1) + require.Equal(t, txTwo.TxHash(), infos[0].Hash) + require.NotNil(t, infos[0].Block) + require.Equal(t, uint32(211), infos[0].Block.Height) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/postgres_txstore_listtxns.go new file mode 100644 index 0000000000..d6977c3364 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_listtxns.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListTxns lists wallet-scoped transactions using either the confirmed-range +// or unmined-only read path. +// +// The no-confirming-block path returns every row without a confirming block, +// including retained invalid history such as orphaned or failed transactions, +// while the confirmed path is bounded by the requested height range. +func (s *PostgresStore) ListTxns(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + if query.UnminedOnly { + return s.listTxnsWithoutBlockPg(ctx, query.WalletID) + } + + return s.listConfirmedTxnsPg(ctx, query) +} + +// listTxnsWithoutBlockPg loads every transaction row that currently has no +// confirming block. This includes the active unmined set together with any +// retained invalid history that rollback or invalidation flows left without a +// confirming block. +func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, + walletID uint32) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + if err != nil { + return nil, fmt.Errorf("list txns without block: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + info, err := txInfoFromPgRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns +// when callers query mined history. +func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + startHeight, err := uint32ToInt32(query.StartHeight) + if err != nil { + return nil, fmt.Errorf("convert start height: %w", err) + } + + endHeight, err := uint32ToInt32(query.EndHeight) + if err != nil { + return nil, fmt.Errorf("convert end height: %w", err) + } + + rows, err := s.queries.ListTransactionsByHeightRange( + ctx, sqlcpg.ListTransactionsByHeightRangeParams{ + WalletID: int64(query.WalletID), + StartHeight: startHeight, + EndHeight: endHeight, + }, + ) + if err != nil { + return nil, fmt.Errorf("list txns by height: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + block, err := buildPgBlock( + row.BlockHeight, + row.BlockHash, + sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, + ) + if err != nil { + return nil, err + } + + info, err := buildTxInfo( + row.TxHash, row.RawTx, row.ReceivedTime, block, + int64(row.TxStatus), row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite_txstore_listtxns.go new file mode 100644 index 0000000000..cc6fb642ef --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_listtxns.go @@ -0,0 +1,94 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListTxns lists wallet-scoped transactions using either the confirmed-range +// or unmined-only read path. +// +// The no-confirming-block path returns every row without a confirming block, +// including retained invalid history such as orphaned or failed transactions, +// while the confirmed path is bounded by the requested height range. +func (s *SqliteStore) ListTxns(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + if query.UnminedOnly { + return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) + } + + return s.listConfirmedTxnsSqlite(ctx, query) +} + +// listTxnsWithoutBlockSqlite loads every transaction row that currently has no +// confirming block. This includes the active unmined set together with any +// retained invalid history that rollback or invalidation flows left without a +// confirming block. +func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, + walletID uint32) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + if err != nil { + return nil, fmt.Errorf("list txns without block: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + info, err := txInfoFromSqliteRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// listConfirmedTxnsSqlite loads the confirmed height-range view used by +// ListTxns when callers query mined history. +func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsByHeightRange( + ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ + WalletID: int64(query.WalletID), + StartHeight: int64(query.StartHeight), + EndHeight: int64(query.EndHeight), + }, + ) + if err != nil { + return nil, fmt.Errorf("list txns by height: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + block, err := buildSqliteBlock( + row.BlockHeight, + row.BlockHash, + sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, + ) + if err != nil { + return nil, err + } + + info, err := buildTxInfo( + row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, + row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} From 9feb56aaf7aac9e983e825fc1f8bcfe6db494a1c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:04:42 +0800 Subject: [PATCH 171/695] wallet: add DeleteTx ops Add the shared DeleteTx orchestration and unmined dependency helpers used by both SQL backends. Landing the common leaf-check and cleanup flow first keeps the backend DeleteTx commit focused on query wiring and integration behavior. --- wallet/internal/db/tx_store_common.go | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index ab9f00cc47..1b8773a6cb 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -27,6 +27,16 @@ var ( // ErrDuplicateInputOutPoint is returned when CreateTx receives the same // previous outpoint more than once. ErrDuplicateInputOutPoint = errors.New("duplicate input outpoint") + + // ErrDeleteRequiresUnmined indicates that DeleteTx only accepts unmined + // transactions. + ErrDeleteRequiresUnmined = errors.New( + "delete requires an unmined transaction", + ) + + // ErrDeleteRequiresLeaf indicates that DeleteTx only accepts unmined + // transactions with no child spenders. + ErrDeleteRequiresLeaf = errors.New("delete requires a leaf transaction") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the @@ -460,3 +470,180 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, return nil } + +// deleteTxOps is the minimal backend adapter the shared DeleteTx workflow +// needs. +// +// The shared delete sequence is: +// - load and validate the target unmined row +// - reject deletes that would orphan direct child spenders +// - restore any wallet-owned parents the tx had marked spent +// - delete wallet-owned outputs created by the tx itself +// - delete the transaction row last +// +// DeleteTx is the ordinary leaf-cleanup path, not the invalidation path. The +// shared helper therefore proves the leaf invariant first and only then unwinds +// the wallet-owned state that points at the target row. +type deleteTxOps interface { + // loadDeleteTarget returns the row ID of the unmined transaction + // DeleteTx is allowed to remove. + loadDeleteTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) + + // ensureLeaf rejects DeleteTx when the target still has direct + // unmined child spenders. + ensureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, + txID int64) error + + // clearSpentUtxos restores any wallet-owned parent outputs the + // transaction had marked spent. + clearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error + + // deleteCreatedUtxos removes any wallet-owned outputs created by the + // transaction being deleted. + deleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error + + // deleteUnminedTransaction removes the target row after its dependent + // wallet state has been cleaned up. + deleteUnminedTransaction(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) +} + +// deleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific +// SQL transaction. +// +// The helper restores wallet-owned parent state before deleting created wallet +// outputs and only removes the transaction row last, so a failed delete cannot +// leave partial wallet bookkeeping behind. +func deleteTxWithOps(ctx context.Context, params DeleteTxParams, + ops deleteTxOps) error { + + txID, err := ops.loadDeleteTarget(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("load delete tx target: %w", err) + } + + err = ops.ensureLeaf(ctx, params.WalletID, params.Txid, txID) + if err != nil { + return fmt.Errorf("check delete tx leaf: %w", err) + } + + err = ops.clearSpentUtxos(ctx, params.WalletID, txID) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + err = ops.deleteCreatedUtxos(ctx, params.WalletID, txID) + if err != nil { + return fmt.Errorf("delete created utxos: %w", err) + } + + rows, err := ops.deleteUnminedTransaction(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("delete unmined tx: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", params.Txid, ErrTxNotFound) + } + + return nil +} + +// unminedTxRecord is the decoded view of one unmined transaction row used by +// shared descendant checks. +type unminedTxRecord struct { + id int64 + hash chainhash.Hash + tx *wire.MsgTx +} + +// extractUnminedTxFn projects one backend-specific unmined transaction row into +// the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. +type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) + +// newUnminedTxRecord decodes one normalized unmined transaction row into the +// shared dependency-walk shape. +func newUnminedTxRecord(id int64, hash []byte, + rawTx []byte) (unminedTxRecord, error) { + + txHash, err := chainhash.NewHash(hash) + if err != nil { + return unminedTxRecord{}, fmt.Errorf("tx hash: %w", err) + } + + tx, err := deserializeMsgTx(rawTx) + if err != nil { + return unminedTxRecord{}, err + } + + return unminedTxRecord{id: id, hash: *txHash, tx: tx}, nil +} + +// buildUnminedTxRecords decodes backend-specific unmined transaction rows into +// the shared dependency-walk shape. +func buildUnminedTxRecords[T any](rows []T, + extract extractUnminedTxFn[T]) ([]unminedTxRecord, error) { + + records := make([]unminedTxRecord, 0, len(rows)) + for _, row := range rows { + id, hash, rawTx := extract(row) + + record, err := newUnminedTxRecord(id, hash, rawTx) + if err != nil { + return nil, fmt.Errorf("decode unmined tx %d: %w", id, err) + } + + records = append(records, record) + } + + return records, nil +} + +// collectDirectChildTxIDs returns the IDs of unmined transactions that directly +// spend any output created by the provided parent hash. +func collectDirectChildTxIDs(parentHash chainhash.Hash, + candidates []unminedTxRecord) []int64 { + + parentHashes := map[chainhash.Hash]struct{}{ + parentHash: {}, + } + + childIDs := make([]int64, 0, len(candidates)) + for _, candidate := range candidates { + if txSpendsAnyParent(candidate.tx, parentHashes) { + childIDs = append(childIDs, candidate.id) + } + } + + return childIDs +} + +// txSpendsAnyParent reports whether the transaction spends any hash in the +// provided parent set. +func txSpendsAnyParent(tx *wire.MsgTx, + parentHashes map[chainhash.Hash]struct{}) bool { + + for _, txIn := range tx.TxIn { + if _, ok := parentHashes[txIn.PreviousOutPoint.Hash]; ok { + return true + } + } + + return false +} + +// isUnminedStatus reports whether a status still represents an unmined +// transaction that DeleteTx may erase. +func isUnminedStatus(status TxStatus) bool { + switch status { + case TxStatusPending, TxStatusPublished: + return true + + case TxStatusReplaced, TxStatusFailed, TxStatusOrphaned: + return false + + default: + return false + } +} From 13643e7ad3118cf04c5b4693ba7ae1a83c0a731d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:05:37 +0800 Subject: [PATCH 172/695] wallet: add DeleteTx Add the postgres and sqlite DeleteTx implementations together with DeleteTx integration coverage. This keeps the backend-specific leaf validation and row cleanup wiring in one commit once the shared delete flow already exists. --- .../internal/db/itest/tx_utxo_store_test.go | 133 +++++++++++++ .../internal/db/postgres_txstore_deletetx.go | 184 +++++++++++++++++ wallet/internal/db/sqlite_txstore_deletetx.go | 187 ++++++++++++++++++ 3 files changed, 504 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_deletetx.go create mode 100644 wallet/internal/db/sqlite_txstore_deletetx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 0f7c414411..5f6dc6e851 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -859,6 +859,139 @@ func TestListTxnsReturnsConfirmedTxsByHeightRange(t *testing.T) { require.Equal(t, uint32(211), infos[0].Block.Height) } +// TestDeleteTxRemovesLeafUnminedTx verifies that DeleteTx removes a leaf +// unmined row and restores any parent spend markers it introduced. +// +// Scenario: +// - One unmined child transaction is the only spender of one wallet-owned +// parent output. +// +// Setup: +// - Create one wallet-owned parent credit and one unmined child spender. +// +// Action: +// - Delete the child through DeleteTx. +// +// Assertions: +// - The child row is removed. +// - The parent output becomes spendable again. +// - No child spend edges remain. +func TestDeleteTxRemovesLeafUnminedTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-leaf") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: childTx.TxHash(), + }) + require.NoError(t, err) + require.Empty(t, childSpendingTxIDs(t, store, walletID, parentTx.TxHash())) + _, ok := txIDByHash(t, store, walletID, childTx.TxHash()) + require.False(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: parentTx.TxHash(), Index: 0, + })) +} + +// TestDeleteTxRejectsNonLeafTx verifies that DeleteTx refuses to erase an +// unmined transaction that still has direct child spenders. +// +// Scenario: +// - One parent transaction still has one direct unmined child spender. +// +// Setup: +// - Create one wallet-owned parent credit and one child that spends it. +// +// Action: +// - Attempt to delete the parent through DeleteTx. +// +// Assertions: +// - DeleteTx returns ErrDeleteRequiresLeaf. +// - Both parent and child rows remain stored. +func TestDeleteTxRejectsNonLeafTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-non-leaf") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001110, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }) + require.ErrorIs(t, err, db.ErrDeleteRequiresLeaf) + _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) + require.True(t, ok) + _, ok = txIDByHash(t, store, walletID, childTx.TxHash()) + require.True(t, ok) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/postgres_txstore_deletetx.go new file mode 100644 index 0000000000..bc157e725d --- /dev/null +++ b/wallet/internal/db/postgres_txstore_deletetx.go @@ -0,0 +1,184 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// DeleteTx atomically removes one unmined transaction and restores any wallet +// UTXO rows that it had spent. +// +// DeleteTx is limited to unmined pending/published rows; confirmed rows and +// terminal invalid-history rows remain part of the wallet timeline. The +// transaction must also be a leaf among the wallet's unmined transactions so +// the delete cannot detach child spenders from their parent history. +func (s *PostgresStore) DeleteTx(ctx context.Context, + params DeleteTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return deleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + }) +} + +// pgDeleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. +type pgDeleteTxOps struct { + qtx *sqlcpg.Queries +} + +var _ deleteTxOps = (*pgDeleteTxOps)(nil) + +// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// allowed to remove. +func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) { + + meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) + if err != nil { + return 0, err + } + + return meta.ID, nil +} + +// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// spenders. +func (o pgDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, + txHash chainhash.Hash, txID int64) error { + + return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) +} + +// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// marked spent. +func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, + txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, + sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: int64(walletID), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxo rows: %w", err) + } + + return nil +} + +// deleteCreatedUtxos removes any wallet-owned outputs created by the +// transaction being deleted. +func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.DeleteUtxosByTxID( + ctx, + sqlcpg.DeleteUtxosByTxIDParams{ + WalletID: int64(walletID), + TxID: txID, + }, + ) + if err != nil { + return fmt.Errorf("delete created utxo rows: %w", err) + } + + return nil +} + +// deleteUnminedTransaction removes the target unmined row after its dependent +// wallet state has been cleaned up. +func (o pgDeleteTxOps) deleteUnminedTransaction(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + rows, err := o.qtx.DeleteUnminedTransactionByHash( + ctx, + sqlcpg.DeleteUnminedTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("delete unmined tx row: %w", err) + } + + return rows, nil +} + +// ensureDeleteLeafPg rejects DeleteTx requests for transactions that still have +// direct unmined child spenders, including children that spend non-credit +// parent outputs. +func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, txID int64) error { + + rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list unmined txns: %w", err) + } + + candidates, err := buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) + if err != nil { + return err + } + + filtered := candidates[:0] + for _, candidate := range candidates { + if candidate.id == txID { + continue + } + + filtered = append(filtered, candidate) + } + + if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + return fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresLeaf) + } + + return nil +} + +// getDeleteTxMetaPg loads the transaction metadata DeleteTx needs and enforces +// the unmined precondition up front. +func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash) ( + sqlcpg.GetTransactionMetaByHashRow, error) { + + meta, err := qtx.GetTransactionMetaByHash( + ctx, sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(int64(meta.TxStatus)) + if err != nil { + return sqlcpg.GetTransactionMetaByHashRow{}, err + } + + if meta.BlockHeight.Valid || !isUnminedStatus(status) { + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresUnmined) + } + + return meta, nil +} diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite_txstore_deletetx.go new file mode 100644 index 0000000000..45d9f6ef6f --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_deletetx.go @@ -0,0 +1,187 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// DeleteTx atomically removes one unmined transaction and restores any wallet +// UTXO rows that it had spent. +// +// DeleteTx is limited to unmined pending/published rows; confirmed rows and +// terminal invalid-history rows remain part of the wallet timeline. The +// transaction must also be a leaf among the wallet's unmined transactions so +// the delete cannot detach child spenders from their parent history. +func (s *SqliteStore) DeleteTx(ctx context.Context, + params DeleteTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return deleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + }) +} + +// sqliteDeleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. +type sqliteDeleteTxOps struct { + qtx *sqlcsqlite.Queries +} + +var _ deleteTxOps = (*sqliteDeleteTxOps)(nil) + +// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// allowed to remove. +func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) + if err != nil { + return 0, err + } + + return meta.ID, nil +} + +// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// spenders. +func (o sqliteDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, + txHash chainhash.Hash, txID int64) error { + + return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) +} + +// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// marked spent. +func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, + sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: int64(walletID), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxo rows: %w", err) + } + + return nil +} + +// deleteCreatedUtxos removes any wallet-owned outputs created by the +// transaction being deleted. +func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.DeleteUtxosByTxID( + ctx, + sqlcsqlite.DeleteUtxosByTxIDParams{ + WalletID: int64(walletID), + TxID: txID, + }, + ) + if err != nil { + return fmt.Errorf("delete created utxo rows: %w", err) + } + + return nil +} + +// deleteUnminedTransaction removes the target unmined row after its dependent +// wallet state has been cleaned up. +func (o sqliteDeleteTxOps) deleteUnminedTransaction(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + rows, err := o.qtx.DeleteUnminedTransactionByHash( + ctx, + sqlcsqlite.DeleteUnminedTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("delete unmined tx row: %w", err) + } + + return rows, nil +} + +// ensureDeleteLeafSqlite rejects DeleteTx requests for transactions that still +// have direct unmined child spenders, including children that spend non-credit +// parent outputs. +func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash, txID int64) error { + + rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list unmined txns: %w", err) + } + + candidates, err := buildUnminedTxRecords( + rows, + func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, + []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) + if err != nil { + return err + } + + filtered := candidates[:0] + for _, candidate := range candidates { + if candidate.id == txID { + continue + } + + filtered = append(filtered, candidate) + } + + if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + return fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresLeaf) + } + + return nil +} + +// getDeleteTxMetaSqlite loads the transaction metadata DeleteTx needs and +// enforces the unmined precondition up front. +func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash) ( + sqlcsqlite.GetTransactionMetaByHashRow, error) { + + meta, err := qtx.GetTransactionMetaByHash( + ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(meta.TxStatus) + if err != nil { + return sqlcsqlite.GetTransactionMetaByHashRow{}, err + } + + if meta.BlockHeight.Valid || !isUnminedStatus(status) { + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresUnmined) + } + + return meta, nil +} From ad53b24fc165572d44204364be41caf9bed1d1a2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:06:03 +0800 Subject: [PATCH 173/695] wallet: add RollbackToBlock ops Add the shared RollbackToBlock orchestration and descendant invalidation helpers used by both SQL backends. Landing the common rollback walk first keeps the backend method commit focused on query adapters and rollback-specific SQL plumbing. --- wallet/internal/db/tx_store_common.go | 154 ++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 1b8773a6cb..3717298c20 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -562,6 +562,50 @@ type unminedTxRecord struct { // the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) +// rollbackToBlockOps adapts one SQL backend to the full RollbackToBlock +// sequence, including sync-state rewinds, block deletion, and descendant +// invalidation. +// +// The shared rollback algorithm is intentionally ordered: +// - collect rollback root hashes before block rows are removed +// - rewind sync-state heights that still point at the removed blocks +// - delete the shared block rows at or above the rollback height +// - mark the disconnected coinbase roots orphaned once their confirming +// blocks have been removed +// - walk the disconnected roots and invalidate dependent descendants +// +// The adapter methods map directly to those stages so the shared helper can own +// the rollback sequencing while the backends keep only query details. +type rollbackToBlockOps interface { + // listRollbackRootHashes returns the coinbase roots disconnected by the + // rollback, grouped by wallet for the later descendant walk. + listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) + + // rewindWalletSyncStateHeights clamps wallet sync-state references + // below the rollback boundary before block rows are removed. + rewindWalletSyncStateHeights(ctx context.Context, height uint32) error + + // deleteBlocksAtOrAboveHeight removes the shared block rows at or above the + // rollback boundary after sync-state references have been rewound. + deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + + // listUnminedTxRecords loads the wallet's current unmined transaction + // rows in the normalized shape the descendant walk expects. + listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) + + // clearDescendantSpends removes any wallet-owned spend edges claimed by one + // invalid descendant before its status is rewritten. + clearDescendantSpends(ctx context.Context, walletID int64, + descendantID int64) error + + // markDescendantsFailed batch-marks the discovered descendants as + // failed once every dependent spend edge has been cleared. + markDescendantsFailed(ctx context.Context, walletID int64, + descendantIDs []int64) error +} + // newUnminedTxRecord decodes one normalized unmined transaction row into the // shared dependency-walk shape. func newUnminedTxRecord(id int64, hash []byte, @@ -619,6 +663,116 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, return childIDs } +// collectDescendantTxIDs returns every unmined transaction that depends on any +// of the provided root hashes, including indirect descendants discovered +// through newly invalidated child hashes. +func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, + candidates []unminedTxRecord) []int64 { + + invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) + for hash := range rootHashes { + invalidHashes[hash] = struct{}{} + } + + invalidIDs := make(map[int64]struct{}, len(candidates)) + for changed := true; changed; { + changed = false + + for _, candidate := range candidates { + if _, ok := invalidIDs[candidate.id]; ok { + continue + } + + if !txSpendsAnyParent(candidate.tx, invalidHashes) { + continue + } + + invalidIDs[candidate.id] = struct{}{} + invalidHashes[candidate.hash] = struct{}{} + changed = true + } + } + + descendantIDs := make([]int64, 0, len(invalidIDs)) + for _, candidate := range candidates { + if _, ok := invalidIDs[candidate.id]; ok { + descendantIDs = append(descendantIDs, candidate.id) + } + } + + return descendantIDs +} + +// invalidateRollbackDescendants clears spend edges and marks failed every +// unmined descendant discovered from the provided wallet-scoped rollback roots. +func invalidateRollbackDescendants(ctx context.Context, + rootHashesByWallet map[uint32]map[chainhash.Hash]struct{}, + ops rollbackToBlockOps) error { + + for walletID, rootHashes := range rootHashesByWallet { + walletID64 := int64(walletID) + + candidates, err := ops.listUnminedTxRecords(ctx, walletID64) + if err != nil { + return fmt.Errorf("list unmined rollback descendants for "+ + "wallet %d: %w", walletID, err) + } + + descendantIDs := collectDescendantTxIDs(rootHashes, candidates) + if len(descendantIDs) == 0 { + continue + } + + for _, descendantID := range descendantIDs { + err = ops.clearDescendantSpends(ctx, walletID64, descendantID) + if err != nil { + return fmt.Errorf("clear rollback descendant spends for "+ + "wallet %d: %w", walletID, err) + } + } + + err = ops.markDescendantsFailed(ctx, walletID64, descendantIDs) + if err != nil { + return fmt.Errorf("mark rollback descendants failed for "+ + "wallet %d: %w", walletID, err) + } + } + + return nil +} + +// rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one +// backend-specific SQL transaction. +// +// The helper rewinds sync-state heights before deleting blocks, then clears and +// fails any now-invalid unmined descendants rooted in disconnected coinbase +// history so rollback cannot leave dangling references behind. +func rollbackToBlockWithOps(ctx context.Context, height uint32, + ops rollbackToBlockOps) error { + + rootHashesByWallet, err := ops.listRollbackRootHashes(ctx, height) + if err != nil { + return fmt.Errorf("list rollback coinbase roots: %w", err) + } + + err = ops.rewindWalletSyncStateHeights(ctx, height) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights: %w", err) + } + + err = ops.deleteBlocksAtOrAboveHeight(ctx, height) + if err != nil { + return fmt.Errorf("delete blocks at or above height: %w", err) + } + + err = invalidateRollbackDescendants(ctx, rootHashesByWallet, ops) + if err != nil { + return err + } + + return nil +} + // txSpendsAnyParent reports whether the transaction spends any hash in the // provided parent set. func txSpendsAnyParent(tx *wire.MsgTx, From 70e726259ada8cb7de399f05b5e6dea4a79e4879 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:07:12 +0800 Subject: [PATCH 174/695] wallet: add RollbackToBlock Add the postgres and sqlite RollbackToBlock implementations together with rollback integration coverage. The rollback tests now exercise confirmed coinbase history through the public CreateTx path so the branch no longer relies on a temporary helper-only workaround. --- .../internal/db/itest/tx_utxo_store_test.go | 164 +++++++++++++++ .../internal/db/postgres_txstore_rollback.go | 191 ++++++++++++++++++ wallet/internal/db/sqlite_txstore_rollback.go | 168 +++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_rollback.go create mode 100644 wallet/internal/db/sqlite_txstore_rollback.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 5f6dc6e851..71ff30e601 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -992,6 +992,170 @@ func TestDeleteTxRejectsNonLeafTx(t *testing.T) { require.True(t, ok) } +// TestDeleteTxRemovesParentWithFailedChild verifies that DeleteTx only treats +// still-active unmined children as leaf blockers. +// +// Scenario: +// - One parent transaction still has one direct child row, but that child has +// already been marked failed. +// +// Setup: +// - Create one wallet-owned parent credit and one child that spends it. +// - Mark the child failed to simulate an already-invalid branch. +// +// Action: +// - Delete the parent through DeleteTx. +// +// Assertions: +// - DeleteTx succeeds because the failed child is no longer part of the +// active unmined graph. +// - The parent row is removed. +func TestDeleteTxRemovesParentWithFailedChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-parent-failed-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001115, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001120, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + setTxStatus(t, store, walletID, childTx.TxHash(), db.TxStatusFailed) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) + require.False(t, ok) +} + +// TestRollbackToBlockFailsCoinbaseDescendants verifies that RollbackToBlock +// marks every unmined descendant of a disconnected coinbase root as failed and +// clears the recorded spend edges they had claimed. +// +// Scenario: +// - One confirmed coinbase credit has one unmined child spender and one +// unmined grandchild spender beneath it. +// +// Setup: +// - Create one wallet-owned coinbase output and confirm it in one block. +// - Insert one child transaction that spends that output and creates one new +// wallet-owned credit. +// - Insert one grandchild that spends the child's wallet-owned output. +// +// Action: +// - Roll back the block that confirmed the coinbase root. +// +// Assertions: +// - Both unmined descendants become failed. +// - The spend edges from the coinbase root and child are cleared. +func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rollback-coinbase-descendants") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + queries := store.Queries() + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + block := CreateBlockFixture(t, queries, 300) + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001200, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: coinbaseTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001210, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + grandchildTx := newRegularTx( + []wire.OutPoint{{Hash: childTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: grandchildTx, + Received: time.Unix(1710001220, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + require.Len(t, childSpendingTxIDs(t, store, walletID, coinbaseTx.TxHash()), + 1) + require.Len(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash()), 1) + + err = store.RollbackToBlock(t.Context(), block.Height) + require.NoError(t, err) + + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, childInfo.Status) + require.Nil(t, childInfo.Block) + + grandchildInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: grandchildTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + require.Nil(t, grandchildInfo.Block) + + require.Empty(t, childSpendingTxIDs(t, store, walletID, coinbaseTx.TxHash())) + require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go new file mode 100644 index 0000000000..769c02598d --- /dev/null +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// RollbackToBlock atomically removes every block at or above the provided +// height and rewrites wallet sync-state references so the block delete can +// succeed. +func (s *PostgresStore) RollbackToBlock(ctx context.Context, + height uint32) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return rollbackToBlockWithOps(ctx, height, + pgRollbackToBlockOps{qtx: qtx}) + }) +} + +// pgRollbackToBlockOps adapts postgres sqlc queries to the shared rollback +// sequence. +type pgRollbackToBlockOps struct { + qtx *sqlcpg.Queries +} + +var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) + +// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// and groups them by wallet. +func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return nil, fmt.Errorf("convert rollback height: %w", err) + } + + rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, rollbackHeight) + if err != nil { + return nil, fmt.Errorf("query rollback coinbase roots: %w", err) + } + + return groupRollbackCoinbaseRootsPg(rows) +} + +// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// rollback boundary before the block rows are deleted. +func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( + ctx context.Context, height uint32) error { + + // PostgreSQL stores block heights as INTEGER today, so rollback still needs + // a checked cast into the current int32-backed schema. On networks with + // Bitcoin's 10-minute target spacing, MaxInt32 would not be reached until + // around year 42839. Regtest can exceed that sooner because blocks are + // mined on demand. + // + // TODO(yy): Fix it when we are in year 42000, which will give us 800 years + // before it's reached. + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return fmt.Errorf("convert rollback height: %w", err) + } + + newHeight := sql.NullInt32{} + if height > 0 { + newHeight, err = uint32ToNullInt32(height - 1) + if err != nil { + return fmt.Errorf("convert new height: %w", err) + } + } + + _, err = o.qtx.RewindWalletSyncStateHeightsForRollback( + ctx, sqlcpg.RewindWalletSyncStateHeightsForRollbackParams{ + RollbackHeight: rollbackHeight, + NewHeight: newHeight, + }, + ) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights query: %w", err) + } + + return nil +} + +// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// references have been rewound. +func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( + ctx context.Context, height uint32) error { + + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return fmt.Errorf("convert rollback height: %w", err) + } + + _, err = o.qtx.DeleteBlocksAtOrAboveHeight(ctx, rollbackHeight) + if err != nil { + return fmt.Errorf("delete blocks at or above height query: %w", err) + } + + return nil +} + +// listUnminedTxRecords loads and decodes every unmined transaction row for the +// wallet so the shared helper can inspect raw inputs for descendant edges. +func (o pgRollbackToBlockOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// invalid descendant transaction before its status is rewritten. +func (o pgRollbackToBlockOps) clearDescendantSpends( + ctx context.Context, walletID int64, descendantID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: descendantID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear descendant spends: %w", err) + } + + return nil +} + +// markDescendantsFailed batch-marks the collected rollback descendants as +// failed once every dependent spend edge has been cleared. +func (o pgRollbackToBlockOps) markDescendantsFailed( + ctx context.Context, walletID int64, descendantIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusFailed), + TxIds: descendantIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark descendants failed: %w", err) + } + + return nil +} + +// groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by +// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( + map[uint32]map[chainhash.Hash]struct{}, error) { + + rootHashesByWallet := make( + map[uint32]map[chainhash.Hash]struct{}, len(rows), + ) + for _, row := range rows { + walletID, err := int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, fmt.Errorf("rollback coinbase hash: %w", err) + } + + if _, ok := rootHashesByWallet[walletID]; !ok { + rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) + } + + rootHashesByWallet[walletID][*txHash] = struct{}{} + } + + return rootHashesByWallet, nil +} diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go new file mode 100644 index 0000000000..90569ff840 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -0,0 +1,168 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// RollbackToBlock atomically removes every block at or above the provided +// height and rewrites wallet sync-state references so the block delete can +// succeed. +func (s *SqliteStore) RollbackToBlock(ctx context.Context, + height uint32) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return rollbackToBlockWithOps(ctx, height, + sqliteRollbackToBlockOps{qtx: qtx}) + }) +} + +// sqliteRollbackToBlockOps adapts sqlite sqlc queries to the shared rollback +// sequence. +type sqliteRollbackToBlockOps struct { + qtx *sqlcsqlite.Queries +} + +var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) + +// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// and groups them by wallet. +func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + + rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) + if err != nil { + return nil, fmt.Errorf("query rollback coinbase roots: %w", err) + } + + return groupRollbackCoinbaseRootsSqlite(rows) +} + +// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// rollback boundary before the block rows are deleted. +func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( + ctx context.Context, height uint32) error { + + newHeight := sql.NullInt64{} + if height > 0 { + newHeight = sql.NullInt64{Int64: int64(height - 1), Valid: true} + } + + _, err := o.qtx.RewindWalletSyncStateHeightsForRollback( + ctx, sqlcsqlite.RewindWalletSyncStateHeightsForRollbackParams{ + RollbackHeight: int64(height), + NewHeight: newHeight, + }, + ) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights query: %w", err) + } + + return nil +} + +// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// references have been rewound. +func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( + ctx context.Context, height uint32) error { + + _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) + if err != nil { + return fmt.Errorf("delete blocks at or above height query: %w", err) + } + + return nil +} + +// listUnminedTxRecords loads and decodes every unmined transaction row for the +// wallet so the shared helper can inspect raw inputs for descendant edges. +func (o sqliteRollbackToBlockOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcsqlite.ListUnminedTransactionsRow) ( + int64, []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// invalid descendant transaction before its status is rewritten. +func (o sqliteRollbackToBlockOps) clearDescendantSpends( + ctx context.Context, walletID int64, descendantID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: descendantID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear descendant spends: %w", err) + } + + return nil +} + +// markDescendantsFailed batch-marks the collected rollback descendants as +// failed once every dependent spend edge has been cleared. +func (o sqliteRollbackToBlockOps) markDescendantsFailed( + ctx context.Context, walletID int64, descendantIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusFailed), + TxIds: descendantIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark descendants failed: %w", err) + } + + return nil +} + +// groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by +// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +func groupRollbackCoinbaseRootsSqlite( + rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( + map[uint32]map[chainhash.Hash]struct{}, error) { + + rootHashesByWallet := make( + map[uint32]map[chainhash.Hash]struct{}, len(rows), + ) + for _, row := range rows { + walletID, err := int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, fmt.Errorf("rollback coinbase hash: %w", err) + } + + if _, ok := rootHashesByWallet[walletID]; !ok { + rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) + } + + rootHashesByWallet[walletID][*txHash] = struct{}{} + } + + return rootHashesByWallet, nil +} From 68af98afe04f9e44095fb5b45ab03d6017fff7b1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:20:29 +0800 Subject: [PATCH 175/695] wallet: add utxo store common helpers Add the shared UTXO-store helpers used by the backend methods that follow. The helper layer centralizes outpoint decoding, public UtxoInfo and LeasedOutput conversion, and the nullable filter adapters required by the sqlite and postgres query bindings. --- wallet/internal/db/utxo_store_common.go | 127 +++++++++++++ wallet/internal/db/utxo_store_common_test.go | 183 +++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 wallet/internal/db/utxo_store_common.go create mode 100644 wallet/internal/db/utxo_store_common_test.go diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go new file mode 100644 index 0000000000..535af7b0c7 --- /dev/null +++ b/wallet/internal/db/utxo_store_common.go @@ -0,0 +1,127 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +var ( + // errInvalidLockID indicates that a lease row contained bytes that cannot + // be represented as a fixed-size LockID. + errInvalidLockID = errors.New("invalid lock id length") + + // ErrOutputAlreadyLeased reports that a UTXO lease request conflicted with + // another active lock on the same output. + ErrOutputAlreadyLeased = errors.New("output already leased") + + // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock + // ID different from the active lease. + ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") +) + +// buildOutPoint converts database tx-hash and output-index fields into a +// wire.OutPoint. +func buildOutPoint(hash []byte, outputIndex uint32) (wire.OutPoint, error) { + txHash, err := chainhash.NewHash(hash) + if err != nil { + return wire.OutPoint{}, fmt.Errorf("utxo hash: %w", err) + } + + return wire.OutPoint{Hash: *txHash, Index: outputIndex}, nil +} + +// buildUtxoInfo converts normalized SQL result fields into the public UtxoInfo +// shape returned by the db interfaces. +func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight *uint32) (*UtxoInfo, error) { + + outPoint, err := buildOutPoint(hash, outputIndex) + if err != nil { + return nil, err + } + + height := UnminedHeight + if blockHeight != nil { + height = *blockHeight + } + + return &UtxoInfo{ + OutPoint: outPoint, + Amount: btcutil.Amount(amount), + PkScript: pkScript, + Received: received.UTC(), + FromCoinBase: isCoinbase, + Height: height, + }, nil +} + +// buildLeasedOutput converts SQL lease-row fields into the public LeasedOutput +// type. +func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, + expiration time.Time) (*LeasedOutput, error) { + + outPoint, err := buildOutPoint(hash, outputIndex) + if err != nil { + return nil, err + } + + if len(lockID) != len(LockID{}) { + return nil, fmt.Errorf("lock id: %w", errInvalidLockID) + } + + var id LockID + copy(id[:], lockID) + + return &LeasedOutput{ + OutPoint: outPoint, + LockID: id, + Expiration: expiration.UTC(), + }, nil +} + +// optionalUint32Int64 converts an optional uint32 filter into the nullable any +// form used by sqlite sqlc queries. +func optionalUint32Int64(value *uint32) any { + if value == nil { + return nil + } + + return int64(*value) +} + +// optionalInt32 converts an optional int32 filter into the nullable any form +// used by sqlite sqlc queries. +func optionalInt32(value *int32) any { + if value == nil { + return nil + } + + return *value +} + +// nullableUint32Int64 converts an optional uint32 filter into the typed null +// form used by postgres sqlc queries. +func nullableUint32Int64(value *uint32) sql.NullInt64 { + if value == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*value), Valid: true} +} + +// nullableInt32 converts an optional int32 filter into the typed null form +// used by postgres sqlc queries. +func nullableInt32(value *int32) sql.NullInt32 { + if value == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *value, Valid: true} +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go new file mode 100644 index 0000000000..2a1405e199 --- /dev/null +++ b/wallet/internal/db/utxo_store_common_test.go @@ -0,0 +1,183 @@ +package db + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/stretchr/testify/require" +) + +// TestBuildOutPoint verifies the common hash/index conversion shared by both +// SQL backends when building a valid public outpoint. +// +// Scenario: +// - One normalized outpoint row is read back from the store. +// Setup: +// - Build a valid transaction hash. +// Action: +// - Convert the normalized hash/index pair into a wire.OutPoint. +// Assertions: +// - The public outpoint preserves the original hash and output index. +func TestBuildOutPoint(t *testing.T) { + t.Parallel() + + // Scenario: One normalized outpoint row is read back from the store. + // Setup: Build a valid transaction hash. + hash := chainhash.Hash{1, 2, 3} + + // Act: Build the public outpoint from normalized DB fields. + outPoint, err := buildOutPoint(hash[:], 7) + + // Assert: The helper preserves the hash and output index. + require.NoError(t, err) + require.Equal(t, hash, outPoint.Hash) + require.Equal(t, uint32(7), outPoint.Index) +} + +// TestBuildOutPoint_InvalidHash verifies that buildOutPoint rejects malformed +// hash bytes. +// +// Scenario: +// - A normalized outpoint row carries malformed hash bytes. +// Setup: +// - Use a short hash payload. +// Action: +// - Attempt to convert the malformed row into a wire.OutPoint. +// Assertions: +// - The helper returns an error instead of building a partial outpoint. +func TestBuildOutPoint_InvalidHash(t *testing.T) { + t.Parallel() + + // Scenario: A normalized outpoint row carries invalid hash bytes. + // Setup: Use a malformed hash payload. + malformedHash := []byte{1, 2, 3} + + // Act: Attempt to build the public outpoint. + _, err := buildOutPoint(malformedHash, 0) + + // Assert: The helper rejects the malformed hash. + require.Error(t, err) +} + +// TestBuildUtxoInfo_Confirmed verifies that buildUtxoInfo preserves confirmed +// UTXO metadata. +// +// Scenario: +// - One confirmed UTXO row is read back from the store. +// Setup: +// - Build a valid hash and confirmed block height. +// Action: +// - Convert the normalized row into a public UtxoInfo value. +// Assertions: +// - The mined height and outpoint metadata are preserved. +func TestBuildUtxoInfo_Confirmed(t *testing.T) { + t.Parallel() + + // Scenario: One confirmed UTXO row is read back from the store. + // Setup: Build a valid hash and confirmed block height. + hash := chainhash.Hash{9} + confirmedHeight := uint32(33) + + // Act: Build the public UTXO view for the confirmed row. + confirmed, err := buildUtxoInfo( + hash[:], 1, 1234, []byte{0x51}, time.Unix(111, 0), true, + &confirmedHeight, + ) + + // Assert: The helper preserves the mined height and outpoint metadata. + require.NoError(t, err) + require.Equal(t, confirmedHeight, confirmed.Height) + require.Equal(t, hash, confirmed.OutPoint.Hash) + require.Equal(t, uint32(1), confirmed.OutPoint.Index) +} + +// TestBuildUtxoInfo_Unconfirmed verifies that buildUtxoInfo maps unconfirmed +// rows to the public unmined sentinel. +// +// Scenario: +// - One unconfirmed UTXO row is read back from the store. +// Setup: +// - Build a valid hash with no block height. +// Action: +// - Convert the normalized row into a public UtxoInfo value. +// Assertions: +// - The missing height maps to UnminedHeight and the timestamp is stored in +// UTC. +func TestBuildUtxoInfo_Unconfirmed(t *testing.T) { + t.Parallel() + + // Scenario: One unconfirmed UTXO row is read back from the store. + // Setup: Build a valid hash with no block height. + hash := chainhash.Hash{9} + + // Act: Build the public UTXO view for the unconfirmed row. + unconfirmed, err := buildUtxoInfo( + hash[:], 2, 5678, []byte{0x52}, time.Unix(222, 0), false, nil, + ) + + // Assert: The helper maps the missing height to UnminedHeight and stores + // timestamps in UTC. + require.NoError(t, err) + require.Equal(t, UnminedHeight, unconfirmed.Height) + require.Equal(t, time.UTC, unconfirmed.Received.Location()) +} + +// TestBuildLeasedOutput verifies the common conversion used by both SQL +// backends when surfacing one valid active lease. +// +// Scenario: +// - One active lease row is read back from the store. +// Setup: +// - Build a valid hash and 32-byte lock ID. +// Action: +// - Convert the normalized row into a public LeasedOutput value. +// Assertions: +// - The outpoint, lock ID, and UTC expiration are preserved. +func TestBuildLeasedOutput(t *testing.T) { + t.Parallel() + + // Scenario: One active lease row is read back from the store. + // Setup: Build a valid hash and 32-byte lock ID. + hash := chainhash.Hash{4, 5, 6} + lockID := make([]byte, 32) + lockID[0] = 7 + + // Act: Build the public leased-output view. + lease, err := buildLeasedOutput( + hash[:], 9, lockID, time.Unix(333, 0).In(time.FixedZone("X", 3600)), + ) + + // Assert: The helper preserves the outpoint, lock ID, and UTC expiration. + require.NoError(t, err) + require.Equal(t, hash, lease.OutPoint.Hash) + require.Equal(t, uint32(9), lease.OutPoint.Index) + require.Equal(t, byte(7), lease.LockID[0]) + require.Equal(t, time.UTC, lease.Expiration.Location()) +} + +// TestBuildLeasedOutput_InvalidLockID verifies that buildLeasedOutput rejects +// malformed lock IDs. +// +// Scenario: +// - A lease row carries an invalid lock ID payload. +// Setup: +// - Build a valid hash with a short lock ID. +// Action: +// - Attempt to convert the malformed row into a public LeasedOutput value. +// Assertions: +// - The helper returns errInvalidLockID. +func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { + t.Parallel() + + // Scenario: A lease row carries an invalid lock ID payload. + // Setup: Build a valid hash with a short lock ID. + hash := chainhash.Hash{4, 5, 6} + shortLockID := []byte{1, 2, 3} + + // Act: Attempt to build the public leased-output view. + _, err := buildLeasedOutput(hash[:], 0, shortLockID, time.Now()) + + // Assert: The helper returns the invalid-lock-ID sentinel. + require.ErrorIs(t, err, errInvalidLockID) +} From 1928c0f10c146aff6a1f650aa658a7efa0bd2652 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:22:22 +0800 Subject: [PATCH 176/695] wallet: add GetUtxo Add the postgres and sqlite GetUtxo implementations together with GetUtxo integration coverage. The method returns one current wallet-owned outpoint view built from normalized query rows and maps missing rows to the public ErrUtxoNotFound sentinel. --- .../internal/db/itest/tx_utxo_store_test.go | 56 +++++++++++++++ .../internal/db/postgres_utxostore_getutxo.go | 71 +++++++++++++++++++ .../internal/db/sqlite_utxostore_getutxo.go | 66 +++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_getutxo.go create mode 100644 wallet/internal/db/sqlite_utxostore_getutxo.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 71ff30e601..e3a7e52e44 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -1156,6 +1157,61 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) } +// TestGetUtxoReturnsCurrentWalletOutput verifies that GetUtxo returns a stored +// wallet-owned output created by an unmined transaction. +func TestGetUtxoReturnsCurrentWalletOutput(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-utxo") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001400, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + utxo, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.NoError(t, err) + require.Equal(t, tx.TxHash(), utxo.OutPoint.Hash) + require.Equal(t, uint32(0), utxo.OutPoint.Index) + require.Equal(t, btcutil.Amount(15000), utxo.Amount) + require.Equal(t, db.UnminedHeight, utxo.Height) +} + +// TestGetUtxoNotFound verifies that GetUtxo returns ErrUtxoNotFound when the +// requested outpoint is not part of the current wallet UTXO set. +func TestGetUtxoNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-utxo-missing") + + _, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: randomOutPoint(), + }) + + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/postgres_utxostore_getutxo.go new file mode 100644 index 0000000000..6ffac436e5 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_getutxo.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetUtxo retrieves one current wallet-owned UTXO by outpoint. +// +// The output must still be unspent and its creating transaction must still be +// in `pending` or `published` status. +func (s *PostgresStore) GetUtxo(ctx context.Context, + query GetUtxoQuery) (*UtxoInfo, error) { + + outputIndex, err := uint32ToInt32(query.OutPoint.Index) + if err != nil { + return nil, fmt.Errorf("convert output index: %w", err) + } + + row, err := s.queries.GetUtxoByOutpoint( + ctx, sqlcpg.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, + ErrUtxoNotFound) + } + + return nil, fmt.Errorf("get utxo: %w", err) + } + + return utxoInfoFromPgRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) +} + +// utxoInfoFromPgRow converts one normalized postgres query row into the public +// UtxoInfo shape. +func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight sql.NullInt32) (*UtxoInfo, error) { + + index, err := int64ToUint32(int64(outputIndex)) + if err != nil { + return nil, fmt.Errorf("utxo output index: %w", err) + } + + var height *uint32 + if blockHeight.Valid { + heightValue, err := nullInt32ToUint32(blockHeight) + if err != nil { + return nil, fmt.Errorf("utxo block height: %w", err) + } + + height = &heightValue + } + + return buildUtxoInfo( + hash, index, amount, pkScript, received, isCoinbase, height, + ) +} diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite_utxostore_getutxo.go new file mode 100644 index 0000000000..dce91783e5 --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_getutxo.go @@ -0,0 +1,66 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetUtxo retrieves one current wallet-owned UTXO by outpoint. +// +// The output must still be unspent and its creating transaction must still be +// in `pending` or `published` status. +func (s *SqliteStore) GetUtxo(ctx context.Context, + query GetUtxoQuery) (*UtxoInfo, error) { + + row, err := s.queries.GetUtxoByOutpoint( + ctx, sqlcsqlite.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: int64(query.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, + ErrUtxoNotFound) + } + + return nil, fmt.Errorf("get utxo: %w", err) + } + + return utxoInfoFromSqliteRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) +} + +// utxoInfoFromSqliteRow converts one normalized sqlite query row into the +// public UtxoInfo shape. +func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight sql.NullInt64) (*UtxoInfo, error) { + + index, err := int64ToUint32(outputIndex) + if err != nil { + return nil, fmt.Errorf("utxo output index: %w", err) + } + + var height *uint32 + if blockHeight.Valid { + heightValue, err := int64ToUint32(blockHeight.Int64) + if err != nil { + return nil, fmt.Errorf("utxo block height: %w", err) + } + + height = &heightValue + } + + return buildUtxoInfo( + hash, index, amount, pkScript, received, isCoinbase, height, + ) +} From 2e91f8725536736033e60c7580d6abde04e8c558 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:23:37 +0800 Subject: [PATCH 177/695] wallet: add ListUTXOs Add the postgres and sqlite ListUTXOs implementations together with ListUTXOs integration coverage. The method returns the current wallet-owned output set after optional account filters while keeping the wallet-ownership constraints in the query layer. --- .../internal/db/itest/tx_utxo_store_test.go | 105 ++++++++++++++++++ .../db/postgres_utxostore_listutxos.go | 68 ++++++++++++ .../internal/db/sqlite_utxostore_listutxos.go | 61 ++++++++++ 3 files changed, 234 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_listutxos.go create mode 100644 wallet/internal/db/sqlite_utxostore_listutxos.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index e3a7e52e44..014738a82e 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1212,6 +1212,111 @@ func TestGetUtxoNotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrUtxoNotFound) } +// TestListUTXOsReturnsCurrentWalletOutputs verifies that ListUTXOs returns the +// current wallet-owned outputs created by pending transactions. +func TestListUTXOsReturnsCurrentWalletOutputs(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-utxos") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 12000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710001500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710001510, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + }) + + require.NoError(t, err) + require.Len(t, utxos, 2) + require.Equal(t, txTwo.TxHash(), utxos[0].OutPoint.Hash) + require.Equal(t, txOne.TxHash(), utxos[1].OutPoint.Hash) +} + +// TestListUTXOsFiltersByAccount verifies that ListUTXOs applies the optional +// account filter without affecting the underlying wallet ownership checks. +func TestListUTXOsFiltersByAccount(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-utxos-account") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "savings") + + defaultAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + savingsAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "savings", false, + ) + + txDefault := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 16000, PkScript: defaultAddr.ScriptPubKey}}, + ) + txSavings := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 17000, PkScript: savingsAddr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txDefault, + Received: time.Unix(1710001600, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txSavings, + Received: time.Unix(1710001610, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + account := uint32(1) + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Account: &account, + }) + + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go new file mode 100644 index 0000000000..550fe1bea6 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -0,0 +1,68 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. +// +// The result set is already constrained to outputs whose creating +// transactions are still in `pending` or `published` status. +func (s *PostgresStore) ListUTXOs(ctx context.Context, + query ListUtxosQuery) ([]UtxoInfo, error) { + + rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) + if err != nil { + return nil, fmt.Errorf("list utxos: %w", err) + } + + utxos := make([]UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromPgRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return nil, err + } + + utxos[i] = *utxo + } + + return utxos, nil +} + +// buildListUtxosParamsPg prepares the typed nullable filters required by the +// postgres ListUtxos query. +func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { + return sqlcpg.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: nullableUint32Int64Pg(query.Account), + MinConfirms: nullableInt32Pg(query.MinConfs), + MaxConfirms: nullableInt32Pg(query.MaxConfs), + } +} + +// nullableUint32Int64Pg converts an optional uint32 filter into the typed null +// form used by postgres sqlc queries. +func nullableUint32Int64Pg(value *uint32) sql.NullInt64 { + if value == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*value), Valid: true} +} + +// nullableInt32Pg converts an optional int32 filter into the typed null form +// used by postgres sqlc queries. +func nullableInt32Pg(value *int32) sql.NullInt32 { + if value == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *value, Valid: true} +} diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go new file mode 100644 index 0000000000..3ef01e8b1c --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. +// +// The result set is already constrained to outputs whose creating +// transactions are still in `pending` or `published` status. +func (s *SqliteStore) ListUTXOs(ctx context.Context, + query ListUtxosQuery) ([]UtxoInfo, error) { + + rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: optionalUint32Int64Sqlite(query.Account), + MinConfirms: optionalInt32Sqlite(query.MinConfs), + MaxConfirms: optionalInt32Sqlite(query.MaxConfs), + }) + if err != nil { + return nil, fmt.Errorf("list utxos: %w", err) + } + + utxos := make([]UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromSqliteRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return nil, err + } + + utxos[i] = *utxo + } + + return utxos, nil +} + +// optionalUint32Int64Sqlite converts an optional uint32 filter into the +// nullable form used by sqlite sqlc queries. +func optionalUint32Int64Sqlite(value *uint32) any { + if value == nil { + return nil + } + + return int64(*value) +} + +// optionalInt32Sqlite converts an optional int32 filter into the nullable form +// used by sqlite sqlc queries. +func optionalInt32Sqlite(value *int32) any { + if value == nil { + return nil + } + + return *value +} From 64cbbcfdd059ffe3ed7271bbd00008bd07b5c042 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:30:03 +0800 Subject: [PATCH 178/695] wallet: add LeaseOutput ops Add the shared LeaseOutput orchestration used by both SQL backends. The shared helper validates lease duration up front and centralizes the missing-utxo versus already-leased decision so the later backend method commit only wires query details and integration coverage. --- wallet/internal/db/utxo_store_common.go | 91 +++++++++----- wallet/internal/db/utxo_store_common_test.go | 126 +++++++++++++++++++ 2 files changed, 188 insertions(+), 29 deletions(-) diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 535af7b0c7..9b655f581c 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -1,7 +1,7 @@ package db import ( - "database/sql" + "context" "errors" "fmt" "time" @@ -23,6 +23,10 @@ var ( // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock // ID different from the active lease. ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") + + // errLeaseOutputNoRow indicates that the backend lease write found no + // leasable current UTXO row for the requested outpoint. + errLeaseOutputNoRow = errors.New("lease output no row") ) // buildOutPoint converts database tx-hash and output-index fields into a @@ -86,42 +90,71 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, }, nil } -// optionalUint32Int64 converts an optional uint32 filter into the nullable any -// form used by sqlite sqlc queries. -func optionalUint32Int64(value *uint32) any { - if value == nil { - return nil - } - - return int64(*value) +// leaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. +// +// The shared lease algorithm is intentionally ordered: +// - validate the public lease request up front +// - attempt the atomic lease write or renewal next +// - if the write reports no row, distinguish a missing UTXO from an active +// conflicting lease +// - return the public leased-output view only after the write succeeds +// +// The adapter methods map directly to those stages so the shared helper keeps +// the policy and sequencing while each backend keeps only query details. +type leaseOutputOps interface { + // acquire attempts to write or renew the lease and returns the stored + // expiration timestamp when the write succeeds. + acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) + + // hasUtxo reports whether the requested outpoint still exists as a current + // wallet-owned UTXO. + hasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) } -// optionalInt32 converts an optional int32 filter into the nullable any form -// used by sqlite sqlc queries. -func optionalInt32(value *int32) any { - if value == nil { - return nil +// leaseOutputWithOps runs the backend-independent LeaseOutput workflow once the +// caller has opened a backend-specific SQL transaction. +// +// The helper owns the lease sequencing so every backend answers the same two +// questions in the same order: did the lease write succeed, and if not, was the +// target output missing or merely already leased by a different lock? +func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, + ops leaseOutputOps) (*LeasedOutput, error) { + + if params.Duration <= 0 { + return nil, fmt.Errorf("%w: lease duration must be positive", + ErrInvalidParam) } - return *value -} + nowUTC := time.Now().UTC() + expiresAt := nowUTC.Add(params.Duration) -// nullableUint32Int64 converts an optional uint32 filter into the typed null -// form used by postgres sqlc queries. -func nullableUint32Int64(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} + expiration, err := ops.acquire(ctx, params, nowUTC, expiresAt) + if err == nil { + return &LeasedOutput{ + OutPoint: params.OutPoint, + LockID: LockID(params.ID), + Expiration: expiration.UTC(), + }, nil } - return sql.NullInt64{Int64: int64(*value), Valid: true} -} + if !errors.Is(err, errLeaseOutputNoRow) { + return nil, fmt.Errorf("acquire utxo lease: %w", err) + } + + // A no-row acquire means the write path found no leasable row. + // Distinguish a missing UTXO from an already-active lease before + // returning a public error. + exists, err := ops.hasUtxo(ctx, params) + if err != nil { + return nil, fmt.Errorf("lookup utxo before lease conflict: %w", err) + } -// nullableInt32 converts an optional int32 filter into the typed null form -// used by postgres sqlc queries. -func nullableInt32(value *int32) sql.NullInt32 { - if value == nil { - return sql.NullInt32{} + if !exists { + return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrUtxoNotFound) } - return sql.NullInt32{Int32: *value, Valid: true} + return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrOutputAlreadyLeased) } diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 2a1405e199..91fbe80768 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -1,10 +1,12 @@ package db import ( + "context" "testing" "time" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/require" ) @@ -181,3 +183,127 @@ func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { // Assert: The helper returns the invalid-lock-ID sentinel. require.ErrorIs(t, err, errInvalidLockID) } + +// TestLeaseOutputWithOps verifies that the shared LeaseOutput helper returns +// the leased outpoint when the backend acquire step succeeds. +func TestLeaseOutputWithOps(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid lease request and one successful stub adapter. + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireExpiration: time.Unix(333, 0).In(time.FixedZone("X", 3600)), + } + + // Act: Run the shared LeaseOutput flow. + lease, err := leaseOutputWithOps(context.Background(), params, ops) + + // Assert: The helper returns the leased outpoint and UTC expiration. + require.NoError(t, err) + require.Equal(t, params.OutPoint, lease.OutPoint) + require.Equal(t, LockID(params.ID), lease.LockID) + require.Equal(t, time.UTC, lease.Expiration.Location()) + require.Equal(t, []string{"acquire"}, ops.calls) +} + +// TestLeaseOutputWithOpsRejectsNonPositiveDuration verifies that the shared +// LeaseOutput helper rejects an already-expired lease request up front. +func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: 0, + } + ops := &stubLeaseOutputOps{} + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.Empty(t, ops.calls) +} + +// TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper +// maps a no-row acquire and missing outpoint lookup to ErrUtxoNotFound. +func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireErr: errLeaseOutputNoRow, + } + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrUtxoNotFound) + require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) +} + +// TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput +// helper maps a no-row acquire and existing outpoint to ErrOutputAlreadyLeased. +func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireErr: errLeaseOutputNoRow, + hasUtxoResult: true, + } + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrOutputAlreadyLeased) + require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) +} + +// stubLeaseOutputOps records how the shared LeaseOutput helper drives one +// backend adapter while letting each test control the returned results. +type stubLeaseOutputOps struct { + acquireExpiration time.Time + acquireErr error + hasUtxoResult bool + + calls []string +} + +var _ leaseOutputOps = (*stubLeaseOutputOps)(nil) + +// acquire records the shared write attempt and returns the test-controlled +// lease result. +func (s *stubLeaseOutputOps) acquire(_ context.Context, + _ LeaseOutputParams, _ time.Time, _ time.Time) (time.Time, error) { + + s.calls = append(s.calls, "acquire") + + return s.acquireExpiration, s.acquireErr +} + +// hasUtxo records the fallback ownership lookup and returns the test-controlled +// result. +func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, + _ LeaseOutputParams) (bool, error) { + + s.calls = append(s.calls, "has-utxo") + + return s.hasUtxoResult, nil +} + +// testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease +// helper tests. +func testLeaseOutPoint() wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} +} From af03f16570de8346c067d3bfdd5d8193d9541b3c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:30:12 +0800 Subject: [PATCH 179/695] wallet: add LeaseOutput Add the postgres and sqlite LeaseOutput implementations together with LeaseOutput integration coverage. The backend adapters now reuse the shared lease flow while keeping the query parameter wiring and lease-write details local to each SQL backend. --- .../internal/db/itest/tx_utxo_store_test.go | 103 +++++++++++++++++ .../db/postgres_utxostore_leaseoutput.go | 108 ++++++++++++++++++ .../db/sqlite_utxostore_leaseoutput.go | 98 ++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_leaseoutput.go create mode 100644 wallet/internal/db/sqlite_utxostore_leaseoutput.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 014738a82e..7d3e39d6c3 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1317,6 +1317,109 @@ func TestListUTXOsFiltersByAccount(t *testing.T) { require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) } +// TestLeaseOutputLocksCurrentUtxo verifies that LeaseOutput returns the active +// lease metadata for a current wallet-owned output. +func TestLeaseOutputLocksCurrentUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 18000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001700, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + lease, err := store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{1}, + Duration: 30 * time.Minute, + }) + + require.NoError(t, err) + require.Equal(t, tx.TxHash(), lease.OutPoint.Hash) + require.Equal(t, uint32(0), lease.OutPoint.Index) + require.Equal(t, db.LockID{1}, lease.LockID) + require.True(t, lease.Expiration.After(time.Now().UTC())) +} + +// TestLeaseOutputRejectsAlreadyLeasedUtxo verifies that LeaseOutput reports +// ErrOutputAlreadyLeased when another active lock already owns the same output. +func TestLeaseOutputRejectsAlreadyLeasedUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 19000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001710, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{1}, + Duration: 30 * time.Minute, + }) + require.NoError(t, err) + + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{2}, + Duration: 30 * time.Minute, + }) + require.ErrorIs(t, err, db.ErrOutputAlreadyLeased) +} + +// TestLeaseOutputRejectsNonPositiveDuration verifies that LeaseOutput rejects a +// non-positive duration before it attempts any lease write. +func TestLeaseOutputRejectsNonPositiveDuration(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output-duration") + + _, err := store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: randomOutPoint(), + ID: db.LockID{3}, + Duration: 0, + }) + + require.ErrorIs(t, err, db.ErrInvalidParam) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/postgres_utxostore_leaseoutput.go new file mode 100644 index 0000000000..e3592f5493 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_leaseoutput.go @@ -0,0 +1,108 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// LeaseOutput atomically acquires or renews a lease for one current UTXO. +// +// The lease lookup and acquisition run in one transaction so competing calls +// cannot observe a partially-written lease. Expiration timestamps are +// normalized to UTC before insert. +func (s *PostgresStore) LeaseOutput(ctx context.Context, + params LeaseOutputParams) (*LeasedOutput, error) { + + var lease *LeasedOutput + + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + acquiredLease, err := leaseOutputWithOps( + ctx, params, &pgLeaseOutputOps{qtx: qtx}, + ) + if err != nil { + return err + } + + lease = acquiredLease + + return nil + }) + if err != nil { + return nil, err + } + + return lease, nil +} + +// pgLeaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput +// workflow. +type pgLeaseOutputOps struct { + qtx *sqlcpg.Queries +} + +var _ leaseOutputOps = (*pgLeaseOutputOps)(nil) + +// acquire attempts to write or renew one postgres lease row for the requested +// outpoint. +func (o *pgLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return time.Time{}, fmt.Errorf("convert output index: %w", err) + } + + expiration, err := o.qtx.AcquireUtxoLease( + ctx, sqlcpg.AcquireUtxoLeaseParams{ + WalletID: int64(params.WalletID), + LockID: params.ID[:], + ExpiresAt: expiresAt, + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + NowUtc: nowUTC, + }, + ) + if err == nil { + return expiration, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, errLeaseOutputNoRow + } + + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) +} + +// hasUtxo reports whether the requested outpoint still exists as a current +// wallet-owned UTXO. +func (o *pgLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return false, fmt.Errorf("convert output index: %w", err) + } + + _, err = o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup utxo row: %w", err) +} diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite_utxostore_leaseoutput.go new file mode 100644 index 0000000000..e3033a27be --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_leaseoutput.go @@ -0,0 +1,98 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// LeaseOutput atomically acquires or renews a lease for one current UTXO. +// +// The lease lookup and acquisition run in one transaction so competing calls +// cannot observe a partially-written lease. Expiration timestamps are +// normalized to UTC before insert. +func (s *SqliteStore) LeaseOutput(ctx context.Context, + params LeaseOutputParams) (*LeasedOutput, error) { + + var lease *LeasedOutput + + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + acquiredLease, err := leaseOutputWithOps( + ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, + ) + if err != nil { + return err + } + + lease = acquiredLease + + return nil + }) + if err != nil { + return nil, err + } + + return lease, nil +} + +// sqliteLeaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput +// workflow. +type sqliteLeaseOutputOps struct { + qtx *sqlcsqlite.Queries +} + +var _ leaseOutputOps = (*sqliteLeaseOutputOps)(nil) + +// acquire attempts to write or renew one sqlite lease row for the requested +// outpoint. +func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) { + + expiration, err := o.qtx.AcquireUtxoLease( + ctx, sqlcsqlite.AcquireUtxoLeaseParams{ + WalletID: int64(params.WalletID), + LockID: params.ID[:], + ExpiresAt: expiresAt, + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, errLeaseOutputNoRow + } + + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + } + + return expiration, nil +} + +// hasUtxo reports whether the requested outpoint still exists as a current +// wallet-owned UTXO. +func (o *sqliteLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { + + _, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup utxo row: %w", err) + } + + return true, nil +} From a749254d426198cd61d12ffdf93167c8bff9a3ce Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:33:20 +0800 Subject: [PATCH 180/695] wallet: add ReleaseOutput ops Add the shared ReleaseOutput orchestration used by both SQL backends. The shared helper centralizes the current-utxo lookup, lease delete, and active-lock fallback so the backend method commit only wires query shapes and integration coverage. --- wallet/internal/db/utxo_store_common.go | 95 +++++++++++++ wallet/internal/db/utxo_store_common_test.go | 135 +++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 9b655f581c..59414517df 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -1,6 +1,7 @@ package db import ( + "bytes" "context" "errors" "fmt" @@ -27,6 +28,19 @@ var ( // errLeaseOutputNoRow indicates that the backend lease write found no // leasable current UTXO row for the requested outpoint. errLeaseOutputNoRow = errors.New("lease output no row") + + // errReleaseOutputUtxoNotFound indicates that ReleaseOutput could not + // resolve the requested outpoint to a current wallet-owned UTXO row. + errReleaseOutputUtxoNotFound = errors.New( + "release output utxo not found", + ) + + // errReleaseOutputNoActiveLease indicates that the target UTXO no longer + // has an active lease row by the time ReleaseOutput checks the fallback + // path. + errReleaseOutputNoActiveLease = errors.New( + "release output no active lease", + ) ) // buildOutPoint converts database tx-hash and output-index fields into a @@ -158,3 +172,84 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, ErrOutputAlreadyLeased) } + +// releaseOutputOps is the backend adapter the shared ReleaseOutput workflow +// uses. +// +// The shared release algorithm is intentionally ordered: +// - resolve the wallet-owned outpoint to a stable UTXO row first +// - attempt the lease delete by lock ID second +// - if no row is deleted, check the active lease state for that UTXO +// - treat a missing active lease as a no-op +// - map a different active lock to ErrOutputUnlockNotAllowed +// +// The adapter methods map directly to those stages so the shared helper keeps +// the release policy and sequencing while each backend keeps only query +// details. +type releaseOutputOps interface { + // lookupUtxoID resolves the current wallet-owned outpoint to its stable + // UTXO row ID. + lookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) + + // release attempts to delete the lease row for the provided UTXO ID and + // lock ID, returning the number of deleted rows. + release(ctx context.Context, walletID uint32, utxoID int64, + lockID [32]byte) (int64, error) + + // activeLockID returns the currently active lock ID for the provided UTXO + // ID. + activeLockID(ctx context.Context, walletID uint32, utxoID int64, + nowUTC time.Time) ([]byte, error) +} + +// releaseOutputWithOps runs the backend-independent ReleaseOutput workflow once +// the caller has opened a backend-specific SQL transaction. +// +// The helper resolves the stable UTXO row first, attempts the lock-specific +// delete second, and only falls back to the active-lock lookup when no row was +// deleted. That keeps a released-or-expired lease as a no-op while still +// surfacing conflicting active locks consistently across backends. +func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, + ops releaseOutputOps) error { + + nowUTC := time.Now().UTC() + + utxoID, err := ops.lookupUtxoID(ctx, params) + if err != nil { + if errors.Is(err, errReleaseOutputUtxoNotFound) { + return fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrUtxoNotFound) + } + + return fmt.Errorf("lookup utxo for release: %w", err) + } + + rows, err := ops.release(ctx, params.WalletID, utxoID, params.ID) + if err != nil { + return fmt.Errorf("release utxo lease: %w", err) + } + + if rows != 0 { + return nil + } + + // No row was deleted, so either the lease already expired or was released, + // or a different active lock still owns this UTXO. + activeLockID, err := ops.activeLockID( + ctx, params.WalletID, utxoID, nowUTC, + ) + if err != nil { + if errors.Is(err, errReleaseOutputNoActiveLease) { + return nil + } + + return fmt.Errorf("lookup active utxo lease: %w", err) + } + + if !bytes.Equal(activeLockID, params.ID[:]) { + return fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrOutputUnlockNotAllowed) + } + + return nil +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 91fbe80768..418e77be22 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -270,6 +270,96 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } +// TestReleaseOutputWithOps verifies that the shared ReleaseOutput helper +// returns nil when the backend delete removes the matching lease row. +func TestReleaseOutputWithOps(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid release request and one successful stub adapter. + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + releaseRows: 1, + } + + // Act: Run the shared ReleaseOutput flow. + err := releaseOutputWithOps(context.Background(), params, ops) + + // Assert: The helper stops after the successful delete path. + require.NoError(t, err) + require.Equal(t, []string{"lookup-utxo", "release"}, ops.calls) +} + +// TestReleaseOutputWithOpsMissingUtxo verifies that the shared ReleaseOutput +// helper maps a missing current outpoint to ErrUtxoNotFound. +func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDErr: errReleaseOutputUtxoNotFound, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrUtxoNotFound) + require.Equal(t, []string{"lookup-utxo"}, ops.calls) +} + +// TestReleaseOutputWithOpsWrongLock verifies that the shared ReleaseOutput +// helper maps a different active lock to ErrOutputUnlockNotAllowed. +func TestReleaseOutputWithOpsWrongLock(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + activeLockIDResult: []byte{1, 2, 3}, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) + require.Equal(t, + []string{"lookup-utxo", "release", "active-lock"}, + ops.calls, + ) +} + +// TestReleaseOutputWithOpsMissingActiveLease verifies that the shared +// ReleaseOutput helper treats an already-expired or already-cleared lease as a +// no-op. +func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + activeLockIDErr: errReleaseOutputNoActiveLease, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.NoError(t, err) + require.Equal(t, + []string{"lookup-utxo", "release", "active-lock"}, + ops.calls, + ) +} + // stubLeaseOutputOps records how the shared LeaseOutput helper drives one // backend adapter while letting each test control the returned results. type stubLeaseOutputOps struct { @@ -302,6 +392,51 @@ func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, return s.hasUtxoResult, nil } +// stubReleaseOutputOps records how the shared ReleaseOutput helper drives one +// backend adapter while letting each test control the returned results. +type stubReleaseOutputOps struct { + lookupUtxoIDResult int64 + lookupUtxoIDErr error + releaseRows int64 + releaseErr error + activeLockIDResult []byte + activeLockIDErr error + + calls []string +} + +var _ releaseOutputOps = (*stubReleaseOutputOps)(nil) + +// lookupUtxoID records the shared outpoint lookup and returns the test- +// controlled result. +func (s *stubReleaseOutputOps) lookupUtxoID(_ context.Context, + _ ReleaseOutputParams) (int64, error) { + + s.calls = append(s.calls, "lookup-utxo") + + return s.lookupUtxoIDResult, s.lookupUtxoIDErr +} + +// release records the shared delete attempt and returns the test-controlled row +// count. +func (s *stubReleaseOutputOps) release(_ context.Context, _ uint32, + _ int64, _ [32]byte) (int64, error) { + + s.calls = append(s.calls, "release") + + return s.releaseRows, s.releaseErr +} + +// activeLockID records the fallback active-lock lookup and returns the test- +// controlled result. +func (s *stubReleaseOutputOps) activeLockID(_ context.Context, _ uint32, + _ int64, _ time.Time) ([]byte, error) { + + s.calls = append(s.calls, "active-lock") + + return s.activeLockIDResult, s.activeLockIDErr +} + // testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease // helper tests. func testLeaseOutPoint() wire.OutPoint { From 80b660de4bcb4ed67e2181bfa0ead1bde66c0d1f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:33:29 +0800 Subject: [PATCH 181/695] wallet: add ReleaseOutput Add the postgres and sqlite ReleaseOutput implementations together with ReleaseOutput integration coverage. The backend adapters now reuse the shared release flow while keeping the query parameter wiring and active-lock lookup details local to each SQL backend. --- .../internal/db/itest/tx_utxo_store_test.go | 100 +++++++++++++++++ .../db/postgres_utxostore_releaseoutput.go | 104 ++++++++++++++++++ .../db/sqlite_utxostore_releaseoutput.go | 99 +++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_releaseoutput.go create mode 100644 wallet/internal/db/sqlite_utxostore_releaseoutput.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 7d3e39d6c3..c9fc40b978 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1420,6 +1420,106 @@ func TestLeaseOutputRejectsNonPositiveDuration(t *testing.T) { require.ErrorIs(t, err, db.ErrInvalidParam) } +// TestReleaseOutputUnlocksMatchingLease verifies that ReleaseOutput removes the +// active lease when the caller presents the matching lock ID. +func TestReleaseOutputUnlocksMatchingLease(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-output") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 20000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001900, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.NoError(t, err) + + otherID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: otherID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) +} + +// TestReleaseOutputRejectsWrongLockID verifies that ReleaseOutput reports the +// public unlock error when another active lock still owns the output. +func TestReleaseOutputRejectsWrongLockID(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 21000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + wrongID := RandomHash() + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: wrongID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.ErrorIs(t, err, db.ErrOutputUnlockNotAllowed) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/postgres_utxostore_releaseoutput.go new file mode 100644 index 0000000000..579118dd17 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_releaseoutput.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ReleaseOutput atomically releases a lease when the caller provides the +// active lock ID. +// +// The ownership check and lease deletion run in one transaction so callers +// cannot unlock a UTXO using stale state from a separate read. +func (s *PostgresStore) ReleaseOutput(ctx context.Context, + params ReleaseOutputParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return releaseOutputWithOps( + ctx, params, &pgReleaseOutputOps{qtx: qtx}, + ) + }) +} + +// pgReleaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput +// workflow. +type pgReleaseOutputOps struct { + qtx *sqlcpg.Queries +} + +var _ releaseOutputOps = (*pgReleaseOutputOps)(nil) + +// lookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO +// row ID. +func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return 0, err + } + + utxoID, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errReleaseOutputUtxoNotFound + } + + return 0, fmt.Errorf("lookup utxo row: %w", err) + } + + return utxoID, nil +} + +// release attempts to delete the postgres lease row for the provided UTXO ID +// and lock ID. +func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + rows, err := o.qtx.ReleaseUtxoLease( + ctx, sqlcpg.ReleaseUtxoLeaseParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + LockID: lockID[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("release lease row: %w", err) + } + + return rows, nil +} + +// activeLockID returns the currently active postgres lease lock ID for the +// provided UTXO ID. +func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, + utxoID int64, nowUTC time.Time) ([]byte, error) { + + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errReleaseOutputNoActiveLease + } + + return nil, fmt.Errorf("lookup active lease row: %w", err) + } + + return activeLockID, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite_utxostore_releaseoutput.go new file mode 100644 index 0000000000..3ecc4d52bb --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_releaseoutput.go @@ -0,0 +1,99 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ReleaseOutput atomically releases a lease when the caller provides the +// active lock ID. +// +// The ownership check and lease deletion run in one transaction so callers +// cannot unlock a UTXO using stale state from a separate read. +func (s *SqliteStore) ReleaseOutput(ctx context.Context, + params ReleaseOutputParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return releaseOutputWithOps( + ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, + ) + }) +} + +// sqliteReleaseOutputOps adapts sqlite sqlc queries to the shared +// ReleaseOutput workflow. +type sqliteReleaseOutputOps struct { + qtx *sqlcsqlite.Queries +} + +var _ releaseOutputOps = (*sqliteReleaseOutputOps)(nil) + +// lookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row +// ID. +func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { + + utxoID, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errReleaseOutputUtxoNotFound + } + + return 0, fmt.Errorf("lookup utxo row: %w", err) + } + + return utxoID, nil +} + +// release attempts to delete the sqlite lease row for the provided UTXO ID and +// lock ID. +func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + rows, err := o.qtx.ReleaseUtxoLease( + ctx, sqlcsqlite.ReleaseUtxoLeaseParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + LockID: lockID[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("release lease row: %w", err) + } + + return rows, nil +} + +// activeLockID returns the currently active sqlite lease lock ID for the +// provided UTXO ID. +func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, + walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { + + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errReleaseOutputNoActiveLease + } + + return nil, fmt.Errorf("lookup active lease row: %w", err) + } + + return activeLockID, nil +} From 39d7625e8edab0f0813bc698c4a3bf8f35494f59 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:34:48 +0800 Subject: [PATCH 182/695] wallet: add ListLeasedOutputs Add the postgres and sqlite ListLeasedOutputs implementations together with integration coverage. The method returns the active wallet lease set as public LeasedOutput values built from normalized query rows. --- .../internal/db/itest/tx_utxo_store_test.go | 93 +++++++++++++++++++ .../postgres_utxostore_listleasedoutputs.go | 45 +++++++++ .../db/sqlite_utxostore_listleasedoutputs.go | 45 +++++++++ 3 files changed, 183 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_listleasedoutputs.go create mode 100644 wallet/internal/db/sqlite_utxostore_listleasedoutputs.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index c9fc40b978..a9c693ce88 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1520,6 +1520,99 @@ func TestReleaseOutputRejectsWrongLockID(t *testing.T) { require.ErrorIs(t, err, db.ErrOutputUnlockNotAllowed) } +// TestListLeasedOutputsReturnsActiveLeases verifies that ListLeasedOutputs +// returns the currently active wallet lease set. +func TestListLeasedOutputsReturnsActiveLeases(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-leases") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 22000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + + require.NoError(t, err) + require.Len(t, leases, 1) + require.Equal(t, tx.TxHash(), leases[0].OutPoint.Hash) + require.Equal(t, db.LockID(leaseID), leases[0].LockID) +} + +// TestListLeasedOutputsExcludesReleasedLease verifies that ListLeasedOutputs +// reflects a successful release immediately. +func TestListLeasedOutputsExcludesReleasedLease(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-leases-after-release") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 23000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002200, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + + require.NoError(t, err) + require.Empty(t, leases) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go new file mode 100644 index 0000000000..1944d7790e --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. +func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]LeasedOutput, error) { + + nowUTC := time.Now().UTC() + + rows, err := s.queries.ListActiveUtxoLeases( + ctx, sqlcpg.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) + if err != nil { + return nil, fmt.Errorf("list active utxo leases: %w", err) + } + + leases := make([]LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := int64ToUint32(int64(row.OutputIndex)) + if err != nil { + return nil, fmt.Errorf("lease output index: %w", err) + } + + lease, err := buildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return nil, err + } + + leases[i] = *lease + } + + return leases, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go new file mode 100644 index 0000000000..4be3fbfee3 --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. +func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]LeasedOutput, error) { + + nowUTC := time.Now().UTC() + + rows, err := s.queries.ListActiveUtxoLeases( + ctx, sqlcsqlite.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) + if err != nil { + return nil, fmt.Errorf("list active utxo leases: %w", err) + } + + leases := make([]LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := int64ToUint32(row.OutputIndex) + if err != nil { + return nil, fmt.Errorf("lease output index: %w", err) + } + + lease, err := buildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return nil, err + } + + leases[i] = *lease + } + + return leases, nil +} From e1e5a37ca194a8a5c882321fa03e6572b6e51514 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:35:54 +0800 Subject: [PATCH 183/695] wallet: add Balance Add the postgres and sqlite Balance implementations together with Balance integration coverage. The balance path returns the current wallet-owned total together with the locked subset computed from the same filtered UTXO base set so callers can reason about lease state without issuing a second query. --- .../internal/db/itest/tx_utxo_store_test.go | 58 +++++++++++++++++++ .../internal/db/postgres_utxostore_balance.go | 34 +++++++++++ .../internal/db/sqlite_utxostore_balance.go | 34 +++++++++++ 3 files changed, 126 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_balance.go create mode 100644 wallet/internal/db/sqlite_utxostore_balance.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index a9c693ce88..1b854d77d1 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1613,6 +1613,64 @@ func TestListLeasedOutputsExcludesReleasedLease(t *testing.T) { require.Empty(t, leases) } +// TestBalanceReturnsTotalAndLocked verifies that Balance returns the filtered +// total UTXO value together with the locked subset covered by active leases. +func TestBalanceReturnsTotalAndLocked(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-balance") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 24000, PkScript: addr.ScriptPubKey}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 26000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710002300, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710002310, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: txOne.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + balance, err := store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + }) + + require.NoError(t, err) + require.Equal(t, btcutil.Amount(50000), balance.Total) + require.Equal(t, btcutil.Amount(24000), balance.Locked) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go new file mode 100644 index 0000000000..91a8e3348b --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Balance returns the sum of wallet-owned current UTXOs after optional filters. +func (s *PostgresStore) Balance(ctx context.Context, + params BalanceParams) (BalanceResult, error) { + + nowUTC := time.Now().UTC() + + balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: nullableUint32Int64Pg(params.Account), + MinConfirms: nullableInt32Pg(params.MinConfs), + MaxConfirms: nullableInt32Pg(params.MaxConfs), + CoinbaseMaturity: nullableInt32Pg(params.CoinbaseMaturity), + }) + if err != nil { + return BalanceResult{}, fmt.Errorf("balance: %w", err) + } + + return BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + }, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go new file mode 100644 index 0000000000..fb5ec0e9e0 --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Balance returns the sum of wallet-owned current UTXOs after optional filters. +func (s *SqliteStore) Balance(ctx context.Context, + params BalanceParams) (BalanceResult, error) { + + nowUTC := time.Now().UTC() + + balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: optionalUint32Int64Sqlite(params.Account), + MinConfirms: optionalInt32Sqlite(params.MinConfs), + MaxConfirms: optionalInt32Sqlite(params.MaxConfs), + CoinbaseMaturity: optionalInt32Sqlite(params.CoinbaseMaturity), + }) + if err != nil { + return BalanceResult{}, fmt.Errorf("balance: %w", err) + } + + return BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + }, nil +} From b3893d7e5e0893a4471d0694952e62b36be61a25 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:16:05 +0800 Subject: [PATCH 184/695] wallet: type sqlite utxo store filters Cast the optional sqlite UTXO filter params in the SQL so sqlc emits typed nullable fields instead of interface{} values. Keep the sqlite helper logic local to the UTXO store files so the common layer stays free of backend-only filter shaping code. --- wallet/internal/db/queries/sqlite/utxos.sql | 34 ++++++------- wallet/internal/db/sqlc/sqlite/utxos.sql.go | 48 +++++++++---------- .../internal/db/sqlite_utxostore_listutxos.go | 19 ++++---- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index e361e37804..e817706381 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -139,12 +139,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - sqlc.narg('account_number') IS NULL - OR acc.account_number = sqlc.narg('account_number') + cast(sqlc.narg('account_number') AS INTEGER) IS NULL + OR acc.account_number = cast(sqlc.narg('account_number') AS INTEGER) ) AND ( - sqlc.narg('min_confirms') IS NULL - OR sqlc.narg('min_confirms') = 0 + cast(sqlc.narg('min_confirms') AS INTEGER) IS NULL + OR cast(sqlc.narg('min_confirms') AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -152,10 +152,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('min_confirms') + ) >= cast(sqlc.narg('min_confirms') AS INTEGER) ) AND ( - sqlc.narg('max_confirms') IS NULL + cast(sqlc.narg('max_confirms') AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -163,7 +163,7 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= sqlc.narg('max_confirms') + ) <= cast(sqlc.narg('max_confirms') AS INTEGER) ) ORDER BY u.amount, t.tx_hash, u.output_index; @@ -220,12 +220,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - sqlc.narg('account_number') IS NULL - OR acc.account_number = sqlc.narg('account_number') + cast(sqlc.narg('account_number') AS INTEGER) IS NULL + OR acc.account_number = cast(sqlc.narg('account_number') AS INTEGER) ) AND ( - sqlc.narg('min_confirms') IS NULL - OR sqlc.narg('min_confirms') = 0 + cast(sqlc.narg('min_confirms') AS INTEGER) IS NULL + OR cast(sqlc.narg('min_confirms') AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -233,10 +233,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('min_confirms') + ) >= cast(sqlc.narg('min_confirms') AS INTEGER) ) AND ( - sqlc.narg('max_confirms') IS NULL + cast(sqlc.narg('max_confirms') AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -244,11 +244,11 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= sqlc.narg('max_confirms') + ) <= cast(sqlc.narg('max_confirms') AS INTEGER) ) AND ( - sqlc.narg('coinbase_maturity') IS NULL - OR sqlc.narg('coinbase_maturity') = 0 + cast(sqlc.narg('coinbase_maturity') AS INTEGER) IS NULL + OR cast(sqlc.narg('coinbase_maturity') AS INTEGER) = 0 OR NOT t.is_coinbase OR ( CASE @@ -257,7 +257,7 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('coinbase_maturity') + ) >= cast(sqlc.narg('coinbase_maturity') AS INTEGER) ); -- name: ListSpendingTxIDsByParentTxID :many diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index 6c41db8c2c..f8b5e110d5 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -44,12 +44,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - ?3 IS NULL - OR acc.account_number = ?3 + cast(?3 AS INTEGER) IS NULL + OR acc.account_number = cast(?3 AS INTEGER) ) AND ( - ?4 IS NULL - OR ?4 = 0 + cast(?4 AS INTEGER) IS NULL + OR cast(?4 AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -57,10 +57,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?4 + ) >= cast(?4 AS INTEGER) ) AND ( - ?5 IS NULL + cast(?5 AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -68,11 +68,11 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= ?5 + ) <= cast(?5 AS INTEGER) ) AND ( - ?6 IS NULL - OR ?6 = 0 + cast(?6 AS INTEGER) IS NULL + OR cast(?6 AS INTEGER) = 0 OR NOT t.is_coinbase OR ( CASE @@ -81,17 +81,17 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?6 + ) >= cast(?6 AS INTEGER) ) ` type BalanceParams struct { NowUtc time.Time WalletID int64 - AccountNumber interface{} - MinConfirms interface{} - MaxConfirms interface{} - CoinbaseMaturity interface{} + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt64 + MaxConfirms sql.NullInt64 + CoinbaseMaturity sql.NullInt64 } type BalanceRow struct { @@ -522,12 +522,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - ?2 IS NULL - OR acc.account_number = ?2 + cast(?2 AS INTEGER) IS NULL + OR acc.account_number = cast(?2 AS INTEGER) ) AND ( - ?3 IS NULL - OR ?3 = 0 + cast(?3 AS INTEGER) IS NULL + OR cast(?3 AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -535,10 +535,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?3 + ) >= cast(?3 AS INTEGER) ) AND ( - ?4 IS NULL + cast(?4 AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -546,16 +546,16 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= ?4 + ) <= cast(?4 AS INTEGER) ) ORDER BY u.amount, t.tx_hash, u.output_index ` type ListUtxosParams struct { WalletID int64 - AccountNumber interface{} - MinConfirms interface{} - MaxConfirms interface{} + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt64 + MaxConfirms sql.NullInt64 } type ListUtxosRow struct { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 3ef01e8b1c..80811a8bfa 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -40,22 +41,22 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, return utxos, nil } -// optionalUint32Int64Sqlite converts an optional uint32 filter into the +// optionalUint32Int64Sqlite converts an optional uint32 filter into the typed // nullable form used by sqlite sqlc queries. -func optionalUint32Int64Sqlite(value *uint32) any { +func optionalUint32Int64Sqlite(value *uint32) sql.NullInt64 { if value == nil { - return nil + return sql.NullInt64{} } - return int64(*value) + return sql.NullInt64{Int64: int64(*value), Valid: true} } -// optionalInt32Sqlite converts an optional int32 filter into the nullable form -// used by sqlite sqlc queries. -func optionalInt32Sqlite(value *int32) any { +// optionalInt32Sqlite converts an optional int32 filter into the typed nullable +// form used by sqlite sqlc queries. +func optionalInt32Sqlite(value *int32) sql.NullInt64 { if value == nil { - return nil + return sql.NullInt64{} } - return *value + return sql.NullInt64{Int64: int64(*value), Valid: true} } From 53aadb422a08b87c9a76c6a9db9f08a78046a10e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:16:05 +0800 Subject: [PATCH 185/695] wallet: document db ops interface pattern Add a short package README describing when the db layer should use shared ops interfaces and how shared and backend-specific code should be laid out. This gives future tx-store and utxo-store work one local reference for the pattern instead of relying on review context alone. --- wallet/internal/db/README.md | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 wallet/internal/db/README.md diff --git a/wallet/internal/db/README.md b/wallet/internal/db/README.md new file mode 100644 index 0000000000..452073bc14 --- /dev/null +++ b/wallet/internal/db/README.md @@ -0,0 +1,109 @@ +# wallet/internal/db + +This package keeps the public wallet-store APIs in one place while supporting +multiple SQL backends. + +## Ops interfaces + +Some store methods use a small backend-specific `ops` interface together with a +shared backend-independent helper. + +Here, `ops` is short for the backend-specific database operations that one +shared workflow needs. + +We use this pattern because postgres and sqlite usually agree on the high-level +wallet workflow, but they still differ in important low-level ways: + +- sqlc generates different parameter and row types for each backend +- nullable arguments often have different shapes, such as typed `sql.Null*` + values in postgres versus sqlite-specific generated forms +- integer widths and conversion helpers differ across backend bindings +- some statements need backend-specific SQL or binding workarounds +- we still want compile-time checking against each backend's concrete query set + +Without a small adapter layer, each backend file tends to duplicate the same +business workflow with only query-shape differences. That makes reviews noisy +and makes later invariant changes easy to miss in one backend. + +Use this pattern when a method has both of these properties: + +- the high-level workflow is the same for postgres and sqlite +- the SQL query types, nullable argument shapes, or row handling differ by + backend + +In that case: + +- keep the shared workflow in one backend-independent helper +- keep the backend adapters close to the concrete backend methods +- keep shared unit tests near the shared workflow +- keep backend-visible behavior in integration tests +- document the algorithm in detail on both the `ops` interface and the shared + helper so reviewers can see the intended sequencing and ownership boundaries + +The abstraction boundary is intentionally narrow: + +- one backend-specific `ops` interface per shared method workflow +- one shared backend-independent workflow helper for that method +- backend-specific preparation, row-count handling, sqlc binding shapes, and + query error mapping stay in backend files + +If a helper is only called from backend-specific files and never from the +shared workflow helper, it should stay in backend-specific files rather than in +the common workflow file. + +This approach is meant to keep one copy of the domain workflow while still +leaving backend-specific SQL details explicit and close to the actual queries. +The shared helper owns the sequencing and invariants. The backend `ops` +implementation owns query calls, generated binding types, backend-specific +conversions, and any helper code that only those backend methods need. + +Examples: + +- tx store: `CreateTx`, `UpdateTx`, `DeleteTx`, `RollbackToBlock` +- utxo store: `LeaseOutput`, `ReleaseOutput` + +Minimal example: + +```go +type someMethodOps interface { + load(ctx context.Context, id int64) (row, error) + write(ctx context.Context, req request) error +} + +func someMethodWithOps(ctx context.Context, req request, + ops someMethodOps) error { + loaded, err := ops.load(ctx, req.id) + if err != nil { + return err + } + + return ops.write(ctx, mergeRequest(req, loaded)) +} +``` + +The shared helper owns the ordering and invariants. Each backend `ops` +implementation only adapts query calls, generated sqlc types, and backend- +specific conversions needed by that shared method. + +The algorithm docs should be explicit. They should describe the ordered stages +of the shared workflow, what each stage is responsible for, and why the shared +helper owns that sequencing instead of leaving it implicit in backend files. + +Do not introduce an `ops` interface for thin read methods or simple wrappers. +If a method is mostly one query plus row conversion, prefer direct backend +implementations. Examples include `GetTx`, `ListTxns`, `GetUtxo`, +`ListUTXOs`, `ListLeasedOutputs`, and `Balance`. + +Likewise, do not add extra common-file helper layers for backend-only prep +work. If a helper only exists to support postgres or sqlite methods and is not +called by the shared workflow helper itself, keep it in backend-specific files. + +## File layout + +- shared helpers and shared workflows should stay backend-independent +- backend-specific implementations should stay close to the concrete backend +- method-specific files are preferred over large backend files that mix many + unrelated methods + +This layout keeps commit boundaries small, makes review easier, and lets shared +logic evolve without hiding backend-specific SQL details. From 386b31aa04fd624389e0669a5e48fb095ed7179b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:39:27 +0800 Subject: [PATCH 186/695] wallet: share nullable SQL casting helpers Add reusable nullable-to-SQL integer helpers in safecasting.go and use them where account and utxo store queries currently hand-roll the same sql.Null conversions. This keeps backend files focused on query wiring while preserving the narrow common-workflow boundary and giving the conversion behavior one place to test. --- wallet/internal/db/accounts_pg.go | 24 ++++------- wallet/internal/db/accounts_sqlite.go | 24 ++++------- .../internal/db/postgres_utxostore_balance.go | 8 ++-- .../db/postgres_utxostore_listutxos.go | 27 ++---------- wallet/internal/db/safecasting.go | 27 ++++++++++++ wallet/internal/db/safecasting_test.go | 42 +++++++++++++++++++ .../internal/db/sqlite_utxostore_balance.go | 8 ++-- .../internal/db/sqlite_utxostore_listutxos.go | 27 ++---------- 8 files changed, 101 insertions(+), 86 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 4019822067..e42f494cb7 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -335,13 +335,10 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, return getAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*query.AccountNumber), - Valid: true, - }, + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } @@ -374,14 +371,11 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, return renameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ - NewName: params.NewName, - WalletID: int64(params.WalletID), - Purpose: int64(params.Scope.Purpose), - CoinType: int64(params.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*params.AccountNumber), - Valid: true, - }, + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index 19ce3ad573..d73d0f18cc 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -331,13 +331,10 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, return getAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*query.AccountNumber), - Valid: true, - }, + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } @@ -369,14 +366,11 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, return renameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ - NewName: params.NewName, - WalletID: int64(params.WalletID), - Purpose: int64(params.Scope.Purpose), - CoinType: int64(params.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*params.AccountNumber), - Valid: true, - }, + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go index 91a8e3348b..5826e64dd7 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *PostgresStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32Int64Pg(params.Account), - MinConfirms: nullableInt32Pg(params.MinConfs), - MaxConfirms: nullableInt32Pg(params.MaxConfs), - CoinbaseMaturity: nullableInt32Pg(params.CoinbaseMaturity), + AccountNumber: nullableUint32ToSQLInt64(params.Account), + MinConfirms: nullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: nullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: nullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go index 550fe1bea6..2a9afdeeb3 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -41,28 +40,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32Int64Pg(query.Account), - MinConfirms: nullableInt32Pg(query.MinConfs), - MaxConfirms: nullableInt32Pg(query.MaxConfs), + AccountNumber: nullableUint32ToSQLInt64(query.Account), + MinConfirms: nullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: nullableInt32ToSQLInt32(query.MaxConfs), } } - -// nullableUint32Int64Pg converts an optional uint32 filter into the typed null -// form used by postgres sqlc queries. -func nullableUint32Int64Pg(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} - -// nullableInt32Pg converts an optional int32 filter into the typed null form -// used by postgres sqlc queries. -func nullableInt32Pg(value *int32) sql.NullInt32 { - if value == nil { - return sql.NullInt32{} - } - - return sql.NullInt32{Int32: *value, Valid: true} -} diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 5393670073..5188a5f608 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -94,6 +94,33 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { return sql.NullInt32{Int32: toInt32, Valid: true}, nil } +// nullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. +func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { + if v == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *v, Valid: true} +} + +// nullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. +func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + +// nullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. +func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + // nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning // an error if the value is out of range or invalid. func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 0dd260851f..4523127a62 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -371,3 +371,45 @@ func TestNullInt32ToUint32(t *testing.T) { }) } } + +// TestNullableInt32ToSQLInt32 checks that optional int32 values become a valid +// sql.NullInt32 only when the pointer is present. +func TestNullableInt32ToSQLInt32(t *testing.T) { + t.Parallel() + + value := int32(42) + + require.Equal(t, sql.NullInt32{}, nullableInt32ToSQLInt32(nil)) + require.Equal(t, + sql.NullInt32{Int32: value, Valid: true}, + nullableInt32ToSQLInt32(&value), + ) +} + +// TestNullableInt32ToSQLInt64 checks that optional int32 values become a valid +// sql.NullInt64 only when the pointer is present. +func TestNullableInt32ToSQLInt64(t *testing.T) { + t.Parallel() + + value := int32(42) + + require.Equal(t, sql.NullInt64{}, nullableInt32ToSQLInt64(nil)) + require.Equal(t, + sql.NullInt64{Int64: int64(value), Valid: true}, + nullableInt32ToSQLInt64(&value), + ) +} + +// TestNullableUint32ToSQLInt64 checks that optional uint32 values become a +// valid sql.NullInt64 only when the pointer is present. +func TestNullableUint32ToSQLInt64(t *testing.T) { + t.Parallel() + + value := uint32(42) + + require.Equal(t, sql.NullInt64{}, nullableUint32ToSQLInt64(nil)) + require.Equal(t, + sql.NullInt64{Int64: int64(value), Valid: true}, + nullableUint32ToSQLInt64(&value), + ) +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go index fb5ec0e9e0..6ab7fd03fb 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *SqliteStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: optionalUint32Int64Sqlite(params.Account), - MinConfirms: optionalInt32Sqlite(params.MinConfs), - MaxConfirms: optionalInt32Sqlite(params.MaxConfs), - CoinbaseMaturity: optionalInt32Sqlite(params.CoinbaseMaturity), + AccountNumber: nullableUint32ToSQLInt64(params.Account), + MinConfirms: nullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: nullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: nullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 80811a8bfa..741b4ce2ae 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -17,9 +16,9 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: optionalUint32Int64Sqlite(query.Account), - MinConfirms: optionalInt32Sqlite(query.MinConfs), - MaxConfirms: optionalInt32Sqlite(query.MaxConfs), + AccountNumber: nullableUint32ToSQLInt64(query.Account), + MinConfirms: nullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: nullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) @@ -40,23 +39,3 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, return utxos, nil } - -// optionalUint32Int64Sqlite converts an optional uint32 filter into the typed -// nullable form used by sqlite sqlc queries. -func optionalUint32Int64Sqlite(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} - -// optionalInt32Sqlite converts an optional int32 filter into the typed nullable -// form used by sqlite sqlc queries. -func optionalInt32Sqlite(value *int32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} From 7b6df405151696c929730d5435a0f70ed5b0d544 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 2 Apr 2026 13:57:43 -0300 Subject: [PATCH 187/695] wallet: improve goroutine-safety in concurrent itests --- .../internal/db/itest/account_store_test.go | 26 ++++++++++++++++--- .../internal/db/itest/address_store_test.go | 26 ++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 5d8c8ecafa..7f2c74819b 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -182,7 +182,13 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { scope := db.KeyScopeBIP0084 const workers = 20 - results := make([]uint32, workers) + + type createResult struct { + number uint32 + err error + } + + resultCh := make(chan createResult, workers) var wg sync.WaitGroup ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -192,6 +198,7 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() + info, err := store.CreateDerivedAccount( ctx, db.CreateDerivedAccountParams{ WalletID: walletID, @@ -199,12 +206,25 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { Name: "acct-concurrent-" + strconv.Itoa(i), }, ) - require.NoError(t, err) - results[i] = info.AccountNumber + if err != nil { + resultCh <- createResult{err: err} + return + } + + resultCh <- createResult{number: info.AccountNumber} }(i) } wg.Wait() + close(resultCh) + + results := make([]uint32, 0, workers) + for result := range resultCh { + require.NoError(t, result.err) + results = append(results, result.number) + } + + require.Len(t, results, workers) // Verify all numbers are unique and sequential. sort.Slice(results, func(i, j int) bool { diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index c62f73751d..2b40c3a6c5 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1059,7 +1059,13 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) const workers = 20 - results := make([]db.AddressInfo, workers) + + type deriveResult struct { + info db.AddressInfo + err error + } + + resultCh := make(chan deriveResult, workers) var wg sync.WaitGroup ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -1070,6 +1076,7 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() + info, err := store.NewDerivedAddress( ctx, db.NewDerivedAddressParams{ WalletID: walletID, @@ -1078,12 +1085,25 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { Change: false, }, deriveFn, ) - require.NoError(t, err) - results[i] = *info + if err != nil { + resultCh <- deriveResult{err: err} + return + } + + resultCh <- deriveResult{info: *info} }(i) } wg.Wait() + close(resultCh) + + results := make([]db.AddressInfo, 0, workers) + for result := range resultCh { + require.NoError(t, result.err) + results = append(results, result.info) + } + + require.Len(t, results, workers) // Verify all indexes are unique and sequential. indexes := make([]uint32, workers) From 4ce540c40156a4474f4c7bb8c55cc94232c97f02 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 2 Apr 2026 13:58:10 -0300 Subject: [PATCH 188/695] wallet: remove timing dependency from timestamp itest --- wallet/internal/db/itest/account_store_test.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 7f2c74819b..7c9078c130 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -529,12 +529,8 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { createdNear time.Time } - // Create three accounts with slight delays to ensure different - // timestamps. var accounts []createdAccount for i := range 3 { - time.Sleep(1 * time.Second) - createdNear := time.Now() params := db.CreateDerivedAccountParams{ WalletID: walletID, @@ -557,11 +553,10 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { 5*time.Second, "account %d CreatedAt should track creation", i) } - // Verify accounts are ordered by creation time. - require.True(t, accounts[0].info.CreatedAt.Before(accounts[1].info.CreatedAt), - "account 0 should have CreatedAt before account 1") - require.True(t, accounts[1].info.CreatedAt.Before(accounts[2].info.CreatedAt), - "account 1 should have CreatedAt before account 2") + require.False(t, accounts[0].info.CreatedAt.After(accounts[1].info.CreatedAt), + "account 0 should not have CreatedAt after account 1") + require.False(t, accounts[1].info.CreatedAt.After(accounts[2].info.CreatedAt), + "account 1 should not have CreatedAt after account 2") } // TestRenameAccount verifies that RenameAccount successfully renames accounts From 5e906be40d7dc78b9bfb61b1f25b409e755be161 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 19:17:50 -0300 Subject: [PATCH 189/695] wallet: add AddressStore interface impl compile check --- wallet/internal/db/addresses_pg.go | 2 ++ wallet/internal/db/addresses_sqlite.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 98c05d19e5..8462f8729b 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -9,6 +9,8 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) +var _ AddressStore = (*PostgresStore)(nil) + // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *PostgresStore) GetAddress(ctx context.Context, diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index c5eb7d353b..a1107f8b71 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -8,6 +8,8 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) +var _ AddressStore = (*SqliteStore)(nil) + // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *SqliteStore) GetAddress(ctx context.Context, From a0bb6a343c1b86dc9e6c9bb9e0e936a1c9842a1d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Mar 2026 18:26:38 -0300 Subject: [PATCH 190/695] wallet: add pagination base --- wallet/internal/db/page/doc.go | 31 ++ wallet/internal/db/page/iter.go | 55 +++ wallet/internal/db/page/iter_test.go | 487 ++++++++++++++++++++++++ wallet/internal/db/page/request.go | 119 ++++++ wallet/internal/db/page/request_test.go | 243 ++++++++++++ wallet/internal/db/page/result.go | 12 + 6 files changed, 947 insertions(+) create mode 100644 wallet/internal/db/page/doc.go create mode 100644 wallet/internal/db/page/iter.go create mode 100644 wallet/internal/db/page/iter_test.go create mode 100644 wallet/internal/db/page/request.go create mode 100644 wallet/internal/db/page/request_test.go create mode 100644 wallet/internal/db/page/result.go diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go new file mode 100644 index 0000000000..b0795cd0f0 --- /dev/null +++ b/wallet/internal/db/page/doc.go @@ -0,0 +1,31 @@ +// Package page provides after-based pagination primitives for SQL-backed +// stores. +// +// # Core types +// +// A [Request] carries the parameters for a single page fetch: page limit, +// and an optional after that identifies where the previous page ended. +// The zero value requests the first page at [DefaultLimit]. +// +// A [Result] carries the items returned by one fetch together with +// [Result.Next]. Pass *Next back to [Request.WithAfter] to advance to the +// next page. +// +// Queries fetch normalizedLimit+1 rows internally and return at most +// normalizedLimit items. If the extra row exists, [Result.Next] is non-nil. +// If it does not, [Result.Next] is nil and the current page is the last page. +// +// # Iterating +// +// [Iter] wraps a fetch function in a standard [iter.Seq2] that pages +// transparently until the list is exhausted or the caller breaks early. +// It propagates fetch errors and respects context cancellation through +// the fetchPage callback. +// +// # Store integration +// +// Stores typically translate [Request.After] into an optional backend +// query parameter, fetch [Request.QueryLimit] rows with a single ordered +// SQL query, map the raw rows to domain items, and then call +// [BuildResult] to derive [Result.Next]. +package page diff --git a/wallet/internal/db/page/iter.go b/wallet/internal/db/page/iter.go new file mode 100644 index 0000000000..9116240f12 --- /dev/null +++ b/wallet/internal/db/page/iter.go @@ -0,0 +1,55 @@ +package page + +import ( + "context" + "iter" +) + +// Iter iterates through paginated results by repeatedly fetching pages and +// yielding items until exhaustion, caller break, error, or cancellation. +// +// Errors are yielded as the second value in the iterator pair. Callers must +// check the error on every iteration before using the yielded item. +func Iter[Query, Item, Cursor any](ctx context.Context, query Query, + fetch func(context.Context, Query) (Result[Item, Cursor], error), + withAfter func(Query, Cursor) Query) iter.Seq2[Item, error] { + + return func(yield func(Item, error) bool) { + var zero Item + + for { + err := ctx.Err() + if err != nil { + yield(zero, err) + + return + } + + result, err := fetch(ctx, query) + if err != nil { + yield(zero, err) + + return + } + + for _, item := range result.Items { + err = ctx.Err() + if err != nil { + yield(zero, err) + + return + } + + if !yield(item, nil) { + return + } + } + + if result.Next == nil { + return + } + + query = withAfter(query, *result.Next) + } + } +} diff --git a/wallet/internal/db/page/iter_test.go b/wallet/internal/db/page/iter_test.go new file mode 100644 index 0000000000..3b574bfbd9 --- /dev/null +++ b/wallet/internal/db/page/iter_test.go @@ -0,0 +1,487 @@ +package page + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// errTest is a sentinel error used across page package tests to +// verify error propagation through pagination helpers. +var errTest = errors.New("test error") + +// intPtr returns a pointer to the given int value. It is used in tests to +// construct Result literals that require a *int after. +func intPtr(v int) *int { + return &v +} + +// TestIterTraversal tests the traversal of items using the page iterator. +func TestIterTraversal(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + wantItems []int + wantFetchCalls int + wantQueries []int + wantCursors []int + }{ + { + name: "empty result", + pages: [][]int{{}}, + wantItems: nil, + wantFetchCalls: 1, + wantQueries: nil, + wantCursors: nil, + }, + { + name: "single non-empty page", + pages: [][]int{{1, 2}}, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantQueries: nil, + wantCursors: nil, + }, + { + name: "multi page traversal", + pages: [][]int{{1, 2}, {3, 4}, {5}}, + wantItems: []int{1, 2, 3, 4, 5}, + wantFetchCalls: 3, + wantQueries: []int{0, 1}, + wantCursors: []int{2, 4}, + }, + { + name: "exact multiple of page limit", + pages: [][]int{{1, 2}, {3, 4}}, + wantItems: []int{1, 2, 3, 4}, + wantFetchCalls: 2, + wantQueries: []int{0}, + wantCursors: []int{2}, + }, + { + name: "limit one traversal", + pages: [][]int{{1}, {2}, {3}}, + wantItems: []int{1, 2, 3}, + wantFetchCalls: 3, + wantQueries: []int{0, 1}, + wantCursors: []int{1, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextQueries []int + nextCursors []int + gotItems []int + ) + + fetchPage := func(_ context.Context, query int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + require.Equal(t, fetchCalls, query) + + items := tc.pages[fetchCalls] + hasMore := fetchCalls < len(tc.pages)-1 + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if hasMore && len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, cursor int) int { + nextQueries = append(nextQueries, query) + nextCursors = append(nextCursors, cursor) + + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantQueries, nextQueries) + require.Equal(t, tc.wantCursors, nextCursors) + }) + } +} + +// TestIterFetchErrorOnFirstCall verifies that Iter yields the error immediately +// when fetchPage fails on the very first call, before any items are produced. +func TestIterFetchErrorOnFirstCall(t *testing.T) { + t.Parallel() + + gotItems := make([]int, 0) + + var iterErr error + + fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { + return Result[int, int]{}, errTest + } + + setCursor := func(_ int, _ int) int { + return 0 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + } + + require.ErrorIs(t, iterErr, errTest) + require.Empty(t, gotItems) +} + +// TestIterFetchErrorAfterTwoPages verifies that Iter yields all items from +// successful pages before propagating an error from a later fetchPage call. +func TestIterFetchErrorAfterTwoPages(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCursors []int + iterErr error + ) + + gotItems := make([]int, 0) + + fetchPage := func( + _ context.Context, _ int, + ) (Result[int, int], error) { + + fetchCalls++ + switch fetchCalls { + case 1: + return Result[int, int]{ + Items: []int{1, 2}, + Next: intPtr(2), + }, nil + case 2: + return Result[int, int]{ + Items: []int{3, 4}, + Next: intPtr(4), + }, nil + default: + return Result[int, int]{}, errTest + } + } + + setCursor := func(query int, cursor int) int { + nextCursors = append(nextCursors, cursor) + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + } + + require.ErrorIs(t, iterErr, errTest) + require.Equal(t, []int{1, 2, 3, 4}, gotItems) + require.Equal(t, 3, fetchCalls) + require.Equal(t, []int{2, 4}, nextCursors) +} + +// TestIterContextCancellation tests cancellation behavior for page iteration. +func TestIterContextCancellation(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + cancelBefore bool + cancelAfterItems int + wantItems []int + wantFetchCalls int + }{ + { + name: "cancelled before first fetch", + pages: [][]int{{1, 2}}, + cancelBefore: true, + wantItems: nil, + wantFetchCalls: 0, + }, + { + name: "cancelled between pages", + pages: [][]int{{1, 2}, {3, 4}}, + cancelAfterItems: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + }, + { + name: "cancel mid-page stops before next yield", + pages: [][]int{ + {1, 2, 3}, + {4, 5}, + }, + cancelAfterItems: 1, + wantItems: []int{1}, + wantFetchCalls: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + gotItems []int + iterErr error + ) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + if tc.cancelBefore { + cancel() + } + + fetchPage := func(ctx context.Context, _ int) (Result[int, int], + error) { + + err := ctx.Err() + if err != nil { + fetchCalls++ + + return Result[int, int]{}, err + } + + require.Less(t, fetchCalls, len(tc.pages)) + + items := tc.pages[fetchCalls] + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, _ int) int { + return query + 1 + } + + for item, err := range Iter( + ctx, 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + if tc.cancelAfterItems > 0 && + len(gotItems) == tc.cancelAfterItems { + + cancel() + } + } + + require.ErrorIs(t, iterErr, context.Canceled) + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + }) + } +} + +// TestIterConsumerBreaks tests the page iterator when the consumer breaks out +// of the loop. +func TestIterConsumerBreaks(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + stopAfter int + wantItems []int + wantFetchCalls int + wantNextCalls int + }{ + { + name: "mid page", + pages: [][]int{{1, 2, 3}, {4, 5}}, + stopAfter: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + { + // The consumer stops after consuming all items in the first page. + // setCursor is never called because the break happens before the + // iterator advances to the next page. + name: "at page boundary", + pages: [][]int{{1, 2}, {3, 4}}, + stopAfter: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCalls int + gotItems []int + ) + + fetchPage := func(_ context.Context, _ int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + + items := tc.pages[fetchCalls] + hasMore := fetchCalls < len(tc.pages)-1 + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if hasMore && len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, cursor int) int { + nextCalls++ + + return query + cursor + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + if len(gotItems) == tc.stopAfter { + break + } + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantNextCalls, nextCalls) + }) + } +} + +// TestIterNextNilTermination verifies Iter stops when Next becomes nil, +// without requiring an extra empty-page fetch. +func TestIterNextNilTermination(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages []Result[int, int] + wantItems []int + wantFetchCalls int + wantNextCalls int + }{ + { + name: "stops on last non-empty page", + pages: []Result[int, int]{ + {Items: []int{1, 2}}, + }, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + { + name: "multi-page, stops mid", + pages: []Result[int, int]{ + {Items: []int{1, 2}, Next: intPtr(2)}, + {Items: []int{3}}, + }, + wantItems: []int{1, 2, 3}, + wantFetchCalls: 2, + wantNextCalls: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCalls int + gotItems []int + ) + + fetchPage := func(_ context.Context, _ int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + + result := tc.pages[fetchCalls] + fetchCalls++ + + return result, nil + } + + setCursor := func(query int, _ int) int { + nextCalls++ + + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantNextCalls, nextCalls) + }) + } +} diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go new file mode 100644 index 0000000000..8653199fab --- /dev/null +++ b/wallet/internal/db/page/request.go @@ -0,0 +1,119 @@ +package page + +const ( + // DefaultLimit is the default number of items that can be returned to a + // page. + DefaultLimit = 100 + + // MaxLimit is the maximum number of items that can be returned to a page. + // Store implementations may fetch MaxLimit+1 rows internally to detect + // whether another page exists. + MaxLimit = 1000 +) + +// Request holds the parameters for a paginated list query. The zero value is +// valid and requests the first page at DefaultLimit. All With* methods +// return a modified shallow copy of the request. +// +// Fields are unexported so that callers must use the With* methods and +// accessor functions. This design preserves the normalization of limit (zero +// maps to DefaultLimit via normalizedLimit()) and ensures consistency across +// all page operations. +type Request[Cursor any] struct { + // limit is the maximum number of items to return per page. + limit uint32 + + // after is the pagination cursor that marks where the next page + // starts after. Nil means the first page. + after *Cursor +} + +// QueryLimit returns the number of rows the SQL query should fetch. Queries +// fetch normalizedLimit+1 rows, so BuildResult can use the extra row as a +// lookahead signal to determine whether another page exists. +func (r Request[Cursor]) QueryLimit() uint32 { + return r.normalizedLimit() + 1 +} + +// WithLimit returns a copy of the request with the limit replaced. The limit is +// not validated here; normalization (zero -> DefaultLimit, over MaxLimit -> +// MaxLimit) happens in normalizedLimit() and QueryLimit(). A caller passing +// 0 or a large value will not see an error, the value will just be normalized +// later. +func (r Request[Cursor]) WithLimit(limit uint32) Request[Cursor] { + r.limit = limit + + return r +} + +// After returns the pagination after from the previous page. +// A false ok return value means the first page is being requested. +func (r Request[Cursor]) After() (Cursor, bool) { + if r.after == nil { + var zero Cursor + + return zero, false + } + + return *r.after, true +} + +// WithAfter returns a copy of the request with the after replaced. +// Calling this on a zero-value Request produces a request for the second page +// (the page after this after). It takes the after by value to avoid +// the caller retaining a pointer into the Request. +func (r Request[Cursor]) WithAfter(after Cursor) Request[Cursor] { + r.after = &after + + return r +} + +// BuildResult assembles a page.Result from a slice of items already fetched by +// the caller. It uses r.normalizedLimit to determine whether the query fetched +// an extra lookahead row. The toCursor function is called on the last item of +// the possibly trimmed slice. +// +// An empty slice always returns an empty result. +// +// If len(items) is greater than normalizedLimit, it trims to normalizedLimit +// and sets Next to the after of the last item in the trimmed slice. +// Otherwise, Next is nil. +func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, + nextOf func(Item) Cursor) Result[Item, Cursor] { + + if len(items) == 0 { + return Result[Item, Cursor]{Items: items} + } + + limit := r.normalizedLimit() + if len(items) <= int(limit) { + return Result[Item, Cursor]{ + Items: items, + } + } + + items = items[:int(limit)] + last := items[len(items)-1] + cursor := nextOf(last) + + return Result[Item, Cursor]{ + Items: items, + Next: &cursor, + } +} + +// normalizedLimit returns the normalized requested page limit for this request. +// A limit of zero returns DefaultLimit. A limit greater than MaxLimit is +// clamped to MaxLimit. +func (r Request[Cursor]) normalizedLimit() uint32 { + switch { + case r.limit == 0: + return DefaultLimit + + case r.limit > MaxLimit: + return MaxLimit + + default: + return r.limit + } +} diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go new file mode 100644 index 0000000000..e1de74ca5a --- /dev/null +++ b/wallet/internal/db/page/request_test.go @@ -0,0 +1,243 @@ +package page + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRequestSize verifies that normalizedLimit normalizes the raw limit field. +func TestRequestSize(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + size uint32 + wantSize uint32 + }{ + { + name: "zero defaults to DefaultLimit", + size: 0, + wantSize: DefaultLimit, + }, + { + name: "minimum in range", + size: 1, + wantSize: 1, + }, + { + name: "exactly MaxLimit passes through", + size: MaxLimit, + wantSize: MaxLimit, + }, + { + name: "over MaxLimit clamped to MaxLimit", + size: uint32(MaxLimit) + 1, + wantSize: MaxLimit, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + request := Request[uint32]{}.WithLimit(tc.size) + require.Equal(t, tc.wantSize, request.normalizedLimit()) + }) + } +} + +// TestQueryLimit verifies that QueryLimit returns normalizedLimit+1, +// including at MaxLimit. +func TestQueryLimit(t *testing.T) { + t.Parallel() + + t.Run("uses limit plus one", func(t *testing.T) { + t.Parallel() + + r := Request[uint32]{}.WithLimit(25) + require.Equal(t, r.normalizedLimit()+1, r.QueryLimit()) + }) + + t.Run("at max page limit", func(t *testing.T) { + t.Parallel() + + r := Request[uint32]{}.WithLimit(MaxLimit) + require.Equal(t, uint32(MaxLimit)+1, r.QueryLimit()) + }) +} + +// TestRequestChaining verifies that chaining With* calls produces the +// expected limit and after on the resulting Request. +func TestRequestChaining(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + request Request[uint32] + wantSize uint32 + wantCursor uint32 + wantHasCursor bool + }{ + { + name: "after nil by default", + request: Request[uint32]{}, + wantSize: DefaultLimit, + wantHasCursor: false, + }, + { + name: "after set without limit", + request: Request[uint32]{}.WithAfter(42), + wantSize: DefaultLimit, + wantCursor: 42, + wantHasCursor: true, + }, + { + name: "limit and after set together", + request: Request[uint32]{}.WithLimit(50).WithAfter(99), + wantSize: 50, + wantCursor: 99, + wantHasCursor: true, + }, + { + name: "after overwrites previous after", + request: Request[uint32]{}.WithAfter(1).WithAfter(2), + wantSize: DefaultLimit, + wantCursor: 2, + wantHasCursor: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.wantSize, tc.request.normalizedLimit()) + + if !tc.wantHasCursor { + _, ok := tc.request.After() + require.False(t, ok) + + return + } + + after, ok := tc.request.After() + require.True(t, ok) + require.Equal(t, tc.wantCursor, after) + }) + } +} + +// TestRequestWithSizeImmutability verifies that WithLimit returns a new +// Request and does not modify the original. +func TestRequestWithSizeImmutability(t *testing.T) { + t.Parallel() + + original := Request[uint32]{}.WithLimit(10).WithAfter(7) + updated := original.WithLimit(20) + + require.Equal(t, uint32(10), original.normalizedLimit()) + originalAfter, ok := original.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) + + require.Equal(t, uint32(20), updated.normalizedLimit()) + updatedAfter, ok := updated.After() + require.True(t, ok) + require.Equal(t, uint32(7), updatedAfter) +} + +// TestRequestWithCursorImmutability verifies that WithAfter returns a new +// Request and does not modify the original. +func TestRequestWithCursorImmutability(t *testing.T) { + t.Parallel() + + original := Request[uint32]{}.WithLimit(10).WithAfter(7) + updated := original.WithAfter(9) + + require.Equal(t, uint32(10), original.normalizedLimit()) + originalAfter, ok := original.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) + + require.Equal(t, uint32(10), updated.normalizedLimit()) + updatedAfter, ok := updated.After() + require.True(t, ok) + require.Equal(t, uint32(9), updatedAfter) +} + +// TestRequestAfterReturnsCursorCopy verifies that mutating the local cursor +// variable returned by After does not mutate the request's internal cursor. +func TestRequestAfterReturnsCursorCopy(t *testing.T) { + t.Parallel() + + request := Request[uint32]{}.WithAfter(7) + after, ok := request.After() + + require.True(t, ok) + require.Equal(t, uint32(7), after) + + after = 9 + require.Equal(t, uint32(9), after) + + originalAfter, ok := request.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) +} + +// TestBuildResult verifies BuildResult assembles the correct Result using the +// lookahead row trimming and Next logic. +func TestBuildResult(t *testing.T) { + t.Parallel() + + toCursor := func(item int) int { return item } + + testCases := []struct { + name string + items []int + size uint32 + wantItems []int + wantNext *int + }{ + { + name: "empty slice returns empty result", + items: []int{}, + size: 100, + wantItems: []int{}, + wantNext: nil, + }, + { + name: "len less than limit leaves Next nil", + items: []int{1, 2}, + size: 5, + wantItems: []int{1, 2}, + wantNext: nil, + }, + { + name: "len equal to limit leaves Next nil", + items: []int{1, 2}, + size: 2, + wantItems: []int{1, 2}, + wantNext: nil, + }, + { + name: "len greater than limit trims and sets Next", + items: []int{1, 2, 3}, + size: 2, + wantItems: []int{1, 2}, + wantNext: func() *int { v := 2; return &v }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := Request[int]{}.WithLimit(tc.size) + result := BuildResult(req, tc.items, toCursor) + + require.Equal(t, tc.wantItems, result.Items) + require.Equal(t, tc.wantNext, result.Next) + }) + } +} diff --git a/wallet/internal/db/page/result.go b/wallet/internal/db/page/result.go new file mode 100644 index 0000000000..4387aa882b --- /dev/null +++ b/wallet/internal/db/page/result.go @@ -0,0 +1,12 @@ +package page + +// Result holds one page of items returned by a paginated list query. +type Result[T any, C any] struct { + // Items contain the results for this page. It may be empty on the last + // page. + Items []T + + // Next is the after to use for fetching the next page. It is nil when + // no more pages exist. + Next *C +} From de963f2794693cd55b0e46051f6993ebf9396bea Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 19:18:36 -0300 Subject: [PATCH 191/695] wallet: add pagination to addresses --- wallet/internal/db/addresses_common.go | 34 +- wallet/internal/db/addresses_pg.go | 86 +++- wallet/internal/db/addresses_sqlite.go | 84 +++- wallet/internal/db/data_types.go | 4 + wallet/internal/db/interface.go | 16 +- .../internal/db/itest/address_store_test.go | 467 +++++++++++++++++- .../postgres/000006_addresses.down.sql | 1 + .../postgres/000006_addresses.up.sql | 4 + .../sqlite/000006_addresses.down.sql | 1 + .../migrations/sqlite/000006_addresses.up.sql | 4 + .../db/queries/postgres/addresses.sql | 24 +- .../internal/db/queries/sqlite/addresses.sql | 24 +- .../db/sqlc/postgres/addresses.sql.go | 24 +- wallet/internal/db/sqlc/postgres/querier.go | 7 +- .../internal/db/sqlc/sqlite/addresses.sql.go | 26 +- wallet/internal/db/sqlc/sqlite/querier.go | 7 +- 16 files changed, 723 insertions(+), 90 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 204712b022..e3e059d531 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -359,36 +359,14 @@ func getAddress[T any, Args any](ctx context.Context, return nil, ErrAddressNotFound } -// listAddresses is a generic helper that retrieves addresses using the provided -// lister function and converts the results to AddressInfo structs. -func listAddresses[T any, Args any](ctx context.Context, - lister func(context.Context, Args) ([]T, error), args Args, - toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { +// nextListAddressesQuery returns a query with its pagination cursor advanced to +// the provided value. +func nextListAddressesQuery(q ListAddressesQuery, + cursor uint32) ListAddressesQuery { - rows, err := lister(ctx, args) - if err != nil { - return nil, fmt.Errorf("list addresses: %w", err) - } - - return addressInfosFromRows(rows, toInfo) -} - -// addressInfosFromRows converts a slice of row types to AddressInfo structs -// using the provided converter function. -func addressInfosFromRows[T any](rows []T, - toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { - - infos := make([]AddressInfo, len(rows)) - for i, row := range rows { - info, err := toInfo(row) - if err != nil { - return nil, err - } - - infos[i] = *info - } + q.Page = q.Page.WithAfter(cursor) - return infos, nil + return q } // derivedAddressAdapters groups the functions needed to create a diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 8462f8729b..7159261ac5 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" "fmt" + "iter" "time" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -31,19 +33,31 @@ func (s *PostgresStore) GetAddress(ctx context.Context, return getAddressByQuery(ctx, query, getByScript) } -// ListAddresses returns a slice of AddressInfo for all addresses in a given -// account. +// ListAddresses returns a page of addresses matching the given query. func (s *PostgresStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) ([]AddressInfo, error) { + query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + + items, err := pgListAddressesByAccount(ctx, s.queries, query) + if err != nil { + return page.Result[AddressInfo, uint32]{}, err + } - return listAddresses( - ctx, s.queries.ListAddressesByAccount, - sqlcpg.ListAddressesByAccountParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountName: query.AccountName, - }, pgAddressRowToInfo, + result := page.BuildResult( + query.Page, items, + func(item AddressInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterAddresses returns an iterator over paginated address results. +func (s *PostgresStore) IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + + return page.Iter( + ctx, query, s.ListAddresses, nextListAddressesQuery, ) } @@ -300,3 +314,53 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { return info, nil } + +// pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, +// and account name, with pagination support. +func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, + query ListAddressesQuery) ([]AddressInfo, error) { + + rows, err := q.ListAddressesByAccount( + ctx, pgBuildAddressPageParams(query), + ) + if err != nil { + return nil, fmt.Errorf("list addresses by account: %w", err) + } + + items := make([]AddressInfo, len(rows)) + for i, row := range rows { + item, err := pgAddressRowToInfo(row) + if err != nil { + return nil, + fmt.Errorf("list addresses by account: map address row: %w", + err) + } + + items[i] = *item + } + + return items, nil +} + +// pgBuildAddressPageParams translates a ListAddresses query to +// ListAddressesByAccount parameters, handling pagination cursors. +func pgBuildAddressPageParams( + q ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + + params := sqlcpg.ListAddressesByAccountParams{ + WalletID: int64(q.WalletID), + Purpose: int64(q.Scope.Purpose), + CoinType: int64(q.Scope.Coin), + AccountName: q.AccountName, + PageLimit: int64(q.Page.QueryLimit()), + } + + if cursor, ok := q.Page.After(); ok { + params.CursorID = sql.NullInt64{ + Int64: int64(cursor), + Valid: true, + } + } + + return params +} diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index a1107f8b71..0e1bbc500e 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -3,8 +3,11 @@ package db import ( "context" "database/sql" + "fmt" + "iter" "time" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -30,19 +33,31 @@ func (s *SqliteStore) GetAddress(ctx context.Context, return getAddressByQuery(ctx, query, getByScript) } -// ListAddresses returns a slice of AddressInfo for all addresses in a given -// account. +// ListAddresses returns a page of addresses matching the given query. func (s *SqliteStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) ([]AddressInfo, error) { + query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + + items, err := sqliteListAddressesByAccount(ctx, s.queries, query) + if err != nil { + return page.Result[AddressInfo, uint32]{}, err + } - return listAddresses( - ctx, s.queries.ListAddressesByAccount, - sqlcsqlite.ListAddressesByAccountParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountName: query.AccountName, - }, sqliteAddressRowToInfo, + result := page.BuildResult( + query.Page, items, + func(item AddressInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterAddresses returns an iterator over paginated address results. +func (s *SqliteStore) IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + + return page.Iter( + ctx, query, s.ListAddresses, nextListAddressesQuery, ) } @@ -293,3 +308,50 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, return info, nil } + +// sqliteListAddressesByAccount lists addresses filtered by wallet ID, key +// scope, and account name, with pagination support. +func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, + query ListAddressesQuery) ([]AddressInfo, error) { + + rows, err := q.ListAddressesByAccount( + ctx, sqliteBuildAddressPageParams(query), + ) + if err != nil { + return nil, fmt.Errorf("list addresses by account: %w", err) + } + + items := make([]AddressInfo, len(rows)) + for i, row := range rows { + item, err := sqliteAddressRowToInfo(row) + if err != nil { + return nil, + fmt.Errorf("list addresses by account: map address row: %w", + err) + } + + items[i] = *item + } + + return items, nil +} + +// sqliteBuildAddressPageParams translates a ListAddresses query to +// ListAddressesByAccount parameters, handling pagination cursors. +func sqliteBuildAddressPageParams( + q ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + + params := sqlcsqlite.ListAddressesByAccountParams{ + WalletID: int64(q.WalletID), + Purpose: int64(q.Scope.Purpose), + CoinType: int64(q.Scope.Coin), + AccountName: q.AccountName, + PageLimit: int64(q.Page.QueryLimit()), + } + + if cursor, ok := q.Page.After(); ok { + params.CursorID = int64(cursor) + } + + return params +} diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 01d3f31d5d..0563522f77 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" ) const ( @@ -711,6 +712,9 @@ type ListAddressesQuery struct { // Scope is the key scope of the account. Scope KeyScope + + // Page holds the pagination parameters for this query. + Page page.Request[uint32] } // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 280e23d012..2e838f9369 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -3,6 +3,9 @@ package db import ( "context" "errors" + "iter" + + "github.com/btcsuite/btcwallet/wallet/internal/db/page" ) var ( @@ -235,10 +238,15 @@ type AddressStore interface { // an error if the address is not found. GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) - // ListAddresses returns a slice of AddressInfo for all addresses in a - // given account. It returns an empty slice if no addresses are found. - ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, - error) + // ListAddresses returns one page of addresses for the given query, + // including a next-cursor for the following page. + ListAddresses(ctx context.Context, query ListAddressesQuery) ( + page.Result[AddressInfo, uint32], error) + + // IterAddresses returns an iterator that fetches pages transparently and + // yields addresses one by one until exhaustion or error. + IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] // GetAddressSecret retrieves the encrypted secret material for a given // address. Returns the AddressSecret containing encrypted private key diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 2b40c3a6c5..b92465da21 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" "github.com/stretchr/testify/require" ) @@ -35,9 +36,9 @@ func mockDeriveFunc() db.AddressDerivationFunc { } } +// newDerivedAddress creates and returns a derived address for testing. func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, scope db.KeyScope, accountName string, change bool) *db.AddressInfo { - t.Helper() info, err := store.NewDerivedAddress( @@ -53,10 +54,11 @@ func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, return info } +// createDerivedAddresses creates and returns a slice of derived addresses for +// testing. func createDerivedAddresses(t *testing.T, store db.AddressStore, walletID uint32, scope db.KeyScope, accountName string, change bool, count int) []db.AddressInfo { - t.Helper() addresses := make([]db.AddressInfo, 0, count) @@ -70,9 +72,9 @@ func createDerivedAddresses(t *testing.T, store db.AddressStore, return addresses } +// getAccountByName retrieves an account by wallet, scope, and name for testing. func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, scope db.KeyScope, accountName string) *db.AccountInfo { - t.Helper() account, err := store.GetAccount( @@ -83,6 +85,45 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, return account } +// collectAddressPages collects paginated address results by iterating +// through all pages from ListAddresses, using cursor pagination until +// Next is nil. +func collectAddressPages(t *testing.T, store db.AddressStore, + query db.ListAddressesQuery) []page.Result[db.AddressInfo, uint32] { + t.Helper() + + pages := make([]page.Result[db.AddressInfo, uint32], 0) + for { + pageResult, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + pages = append(pages, pageResult) + + if pageResult.Next == nil { + return pages + } + + query.Page = query.Page.WithAfter(*pageResult.Next) + } +} + +// flattenAddressPages flattens paginated address results into a single +// slice containing all addresses from all pages. +func flattenAddressPages( + pages []page.Result[db.AddressInfo, uint32]) []db.AddressInfo { + + count := 0 + for i := range pages { + count += len(pages[i].Items) + } + + addresses := make([]db.AddressInfo, 0, count) + for i := range pages { + addresses = append(addresses, pages[i].Items...) + } + + return addresses +} + // TestNewImportedAddress verifies that NewImportedAddress correctly imports // addresses of different types, both watch-only and spendable. func TestNewImportedAddress(t *testing.T) { @@ -236,7 +277,9 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") + createImportedAccount( + t, store, walletID, db.KeyScopeBIP0049Plus, "imported", + ) redeemScript := RandomBytes(32) witnessScript := RandomBytes(48) @@ -661,9 +704,9 @@ func TestGetAddress(t *testing.T) { } } -// TestListAddresses verifies that ListAddresses correctly returns all -// addresses for an account in a specified scope, filters by scope -// appropriately, and handles empty results without error. +// TestListAddresses verifies that ListAddresses correctly returns addresses +// with page-contract behavior, filters by scope appropriately, and handles +// empty results without error. func TestListAddresses(t *testing.T) { t.Parallel() @@ -780,7 +823,7 @@ func TestListAddresses(t *testing.T) { walletID := newWallet(t, store, tc.name+"-wallet") query := tc.setupFunc(t, store, store, walletID) - addrs, err := store.ListAddresses(t.Context(), query) + pageResult, err := store.ListAddresses(t.Context(), query) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) @@ -788,6 +831,7 @@ func TestListAddresses(t *testing.T) { } require.NoError(t, err) + addrs := pageResult.Items require.Len(t, addrs, tc.wantCount) if tc.validate != nil { @@ -946,7 +990,7 @@ func TestListAddressesOrdering(t *testing.T) { t, store, walletID, db.KeyScopeBIP0084, "ordering-account", true, 3, ) - addresses, err := store.ListAddresses( + pageResult, err := store.ListAddresses( t.Context(), db.ListAddressesQuery{ WalletID: walletID, @@ -956,6 +1000,7 @@ func TestListAddressesOrdering(t *testing.T) { ) require.NoError(t, err) + addresses := pageResult.Items require.Len(t, addresses, 6) // Separate addresses by branch for verification. @@ -987,6 +1032,410 @@ func TestListAddressesOrdering(t *testing.T) { } } +// TestListAddressesPagination verifies that ListAddresses paginates correctly +// and sets Next without requiring an extra round-trip. +func TestListAddressesPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-strong-mode") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "account-a") + createDerivedAccount(t, store, walletID, scope, "account-b") + + accountA := createDerivedAddresses( + t, store, walletID, scope, "account-a", false, 5, + ) + createDerivedAddresses(t, store, walletID, scope, "account-b", false, 2) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "account-a", + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.Equal(t, accountA[:2], page1.Items) + require.NotNil(t, page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, accountA[2:4], page2.Items) + require.NotNil(t, page2.Next) + + query.Page = query.Page.WithAfter(*page2.Next) + page3, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page3.Items, 1) + require.Equal(t, accountA[4:], page3.Items) + require.Nil(t, page3.Next) + + query.Page = query.Page.WithAfter(page3.Items[len(page3.Items)-1].ID) + page4, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page4.Items) + require.Nil(t, page4.Next) + + paged := append([]db.AddressInfo{}, page1.Items...) + paged = append(paged, page2.Items...) + paged = append(paged, page3.Items...) + require.Equal(t, accountA, paged) + for i, addr := range paged { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + } +} + +// TestListAddressesExactBoundary verifies that pagination correctly handles +// the exact boundary case where total results equal page-size multiples. +func TestListAddressesExactBoundary(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "boundary-wallet") + scope := db.KeyScopeBIP0084 + accountName := "boundary-account" + + createDerivedAccount(t, store, walletID, scope, accountName) + expected := createDerivedAddresses( + t, store, walletID, scope, accountName, false, 4, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Equal(t, expected[:2], page1.Items) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Equal(t, expected[2:], page2.Items) + require.Nil(t, page2.Next) + require.Greater(t, page2.Items[0].ID, *page1.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) +} + +// TestListAddressesPagedEmptyResult verifies that paginated ListAddresses +// returns an empty result with no cursor for an account with no addresses. +func TestListAddressesPagedEmptyResult(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-empty") + scope := db.KeyScopeBIP0084 + pageSize := uint(2) + + createDerivedAccount(t, store, walletID, scope, "empty-account") + + pageResult, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "empty-account", + Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + }) + require.NoError(t, err) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) +} + +// TestListAddressesDeterministicPagination verifies stable ID-ordered address +// pagination and next-cursor behavior. +func TestListAddressesDeterministicPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-deterministic") + scope := db.KeyScopeBIP0084 + accountName := "deterministic-account" + createDerivedAccount(t, store, walletID, scope, accountName) + expected := createDerivedAddresses( + t, store, walletID, scope, accountName, false, 5, + ) + + pages := collectAddressPages(t, store, db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}.WithLimit(2), + }) + require.Len(t, pages, 3) + require.Len(t, pages[0].Items, 2) + require.Len(t, pages[1].Items, 2) + require.Len(t, pages[2].Items, 1) + require.NotNil(t, pages[0].Next) + require.NotNil(t, pages[1].Next) + require.Nil(t, pages[2].Next) + + addresses := flattenAddressPages(pages) + require.Equal(t, expected, addresses) + + seenIDs := make(map[uint32]struct{}, len(addresses)) + for i := range pages { + for j, addr := range pages[i].Items { + _, duplicate := seenIDs[addr.ID] + require.False(t, duplicate) + seenIDs[addr.ID] = struct{}{} + + // Skip the first item on the first page; there's no prior cursor + // to compare against. + if i == 0 && j == 0 { + continue + } + + // First item on a later page: verify it sorts strictly after the + // previous page's cursor to ensure no gaps or duplicates at page + // boundaries. + if j == 0 { + require.Greater(t, addr.ID, *pages[i-1].Next) + continue + } + + // Items within the same page: verify strict ordering to ensure + // the page contents are sorted. + require.Greater(t, addr.ID, pages[i].Items[j-1].ID) + } + } + + for i := range addresses { + if i == 0 { + continue + } + + require.Less(t, addresses[i-1].ID, addresses[i].ID) + } +} + +// TestListAddressesAccountIsolation verifies that ListAddresses returns only +// addresses for the requested account, excluding addresses from other accounts. +func TestListAddressesAccountIsolation(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-account-isolation") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "account-a") + createDerivedAccount(t, store, walletID, scope, "account-b") + + expected := createDerivedAddresses( + t, store, walletID, scope, "account-a", false, 5, + ) + otherAccount := createDerivedAddresses( + t, store, walletID, scope, "account-b", false, 3, + ) + + pages := collectAddressPages(t, store, db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "account-a", + Page: page.Request[uint32]{}.WithLimit(2), + }) + addresses := flattenAddressPages(pages) + + require.Len(t, addresses, len(expected)) + require.Equal(t, expected, addresses) + accountAID := expected[0].AccountID + accountBID := otherAccount[0].AccountID + for _, addr := range addresses { + require.Equal(t, accountAID, addr.AccountID) + require.NotEqual(t, accountBID, addr.AccountID) + } +} + +// TestListAddressesInsertAfterCursor verifies inserts after page N are +// returned on page N+1 when pagination uses increasing ID cursors. +func TestListAddressesInsertAfterCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-boundary-insert") + scope := db.KeyScopeBIP0084 + accountName := "boundary-account" + createDerivedAccount(t, store, walletID, scope, accountName) + createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2), + } + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + + // Pagination is by increasing address ID (id > cursor). + // An address created after page 1 should therefore appear on page 2. + inserted := newDerivedAddress( + t, store, walletID, scope, accountName, false, + ) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, uint32(2), page2.Items[0].Index) + require.Equal(t, inserted.ID, page2.Items[1].ID) + require.Equal(t, uint32(3), page2.Items[1].Index) + require.Nil(t, page2.Next) +} + +// TestListAddressesCursorEdges verifies stale and zero-value cursors produce +// deterministic page results. +func TestListAddressesCursorEdges(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-cursor-edges") + scope := db.KeyScopeBIP0084 + accountName := "cursor-account" + createDerivedAccount(t, store, walletID, scope, accountName) + createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + + stalePage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2). + WithAfter(math.MaxUint32), + }) + require.NoError(t, err) + require.Empty(t, stalePage.Items) + require.Nil(t, stalePage.Next) + + zeroPage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2). + WithAfter(0), + }) + require.NoError(t, err) + require.Len(t, zeroPage.Items, 2) + require.Equal(t, uint32(0), zeroPage.Items[0].Index) + require.Equal(t, uint32(1), zeroPage.Items[1].Index) + require.NotNil(t, zeroPage.Next) +} + +// TestIterAddresses verifies that IterAddresses yields the same addresses in +// the same order as manual cursor-based pagination. +func TestIterAddresses(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses") + scope := db.KeyScopeBIP0084 + pageSize := uint(2) + + createDerivedAccount(t, store, walletID, scope, "iter-account") + expected := createDerivedAddresses( + t, store, walletID, scope, "iter-account", false, 5, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "iter-account", + Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + } + + iterAddrs := make([]db.AddressInfo, 0, len(expected)) + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + iterAddrs = append(iterAddrs, addr) + } + + paged := flattenAddressPages(collectAddressPages(t, store, query)) + require.Equal(t, expected, paged) + require.Equal(t, expected, iterAddrs) + require.Equal(t, paged, iterAddrs) +} + +// TestIterAddressesPaginated verifies that IterAddresses produces the same +// results as manual pagination and correctly signals end-of-list. +func TestIterAddressesPaginated(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses-early") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "iter-account") + createDerivedAddresses( + t, store, walletID, scope, "iter-account", false, 4, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "iter-account", + Page: page.Request[uint32]{}. + WithLimit(2), + } + + pages := collectAddressPages(t, store, query) + require.Len(t, pages, 2) + require.NotNil(t, pages[0].Next) + require.Nil(t, pages[1].Next) + + expected := flattenAddressPages(pages) + + iterAddrs := make([]db.AddressInfo, 0, len(expected)) + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + iterAddrs = append(iterAddrs, addr) + } + + require.Equal(t, expected, iterAddrs) +} + +// TestIterAddressesEmpty verifies that IterAddresses correctly handles +// empty accounts without error and yields no addresses. +func TestIterAddressesEmpty(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses-empty") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "empty-account") + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "empty-account", + Page: page.Request[uint32]{}.WithLimit(10), + } + + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + require.Failf(t, "unexpected address", "address=%v", addr) + } +} + // TestNewDerivedAddressErrors verifies that NewDerivedAddress returns // appropriate errors for invalid parameters, including non-existent // accounts, unknown key scopes, and empty account names. diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql index 92ce9f0dd2..ea5ce7ef37 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql @@ -1,4 +1,5 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 2c13ad1a4b..2e9c79ddcb 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -76,6 +76,10 @@ ON addresses (account_id, script_pub_key); -- Used by GetAddressByScriptPubKey. CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Index on (account_id, id) for efficient pagination of addresses by account. +-- Used by ListAddressesByAccount for cursor-based pagination. +CREATE INDEX idx_addresses_account_id ON addresses (account_id, id); + -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql index 92ce9f0dd2..ea5ce7ef37 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql @@ -1,4 +1,5 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 39e5f96d69..72a58dc139 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -74,6 +74,10 @@ ON addresses (account_id, script_pub_key); -- Used by GetAddressByScriptPubKey. CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Index on (account_id, id) for efficient pagination of addresses by account. +-- Used by ListAddressesByAccount for cursor-based pagination. +CREATE INDEX idx_addresses_account_id ON addresses (account_id, id); + -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index c566ac3159..a76546339a 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -71,9 +71,10 @@ INSERT INTO addresses ( RETURNING id, created_at; -- name: ListAddressesByAccount :many --- Lists all addresses for a given account identified by wallet_id, key scope --- (purpose/coin_type), and account name. Returns all address columns for --- filtering and processing by the application. +-- Lists addresses for an account identified by wallet_id, key scope +-- (purpose/coin_type), and account name, ordered by address ID. +-- When cursor_id is provided, only rows strictly after that address ID are +-- returned. Returns up to page_limit rows. SELECT a.id, a.account_id, @@ -91,6 +92,17 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND acc.account_name = $4 -ORDER BY a.id; + ks.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND acc.account_name = sqlc.arg('account_name') + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + sqlc.narg('cursor_id')::BIGINT IS NULL -- noqa: RF02 + OR a.id > sqlc.narg('cursor_id')::BIGINT -- noqa: RF02 + ) +ORDER BY a.id +LIMIT sqlc.arg('page_limit')::BIGINT; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index d195991d7e..dc9db4a85d 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -71,9 +71,10 @@ INSERT INTO addresses ( RETURNING id, created_at; -- name: ListAddressesByAccount :many --- Lists all addresses for a given account identified by wallet_id, key scope --- (purpose/coin_type), and account name. Returns all address columns for --- filtering and processing by the application. +-- Lists addresses for an account identified by wallet_id, key scope +-- (purpose/coin_type), and account name, ordered by address ID. +-- When cursor_id is provided, only rows strictly after that address ID are +-- returned. Returns up to page_limit rows. SELECT a.id, a.account_id, @@ -91,6 +92,17 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ? -ORDER BY a.id; + ks.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND acc.account_name = sqlc.arg('account_name') + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + sqlc.narg('cursor_id') IS NULL -- noqa: RF02 + OR a.id > sqlc.narg('cursor_id') -- noqa: RF02 + ) +ORDER BY a.id +LIMIT sqlc.arg('page_limit'); diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index 3d5b0c0777..3ffd8b851a 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -220,9 +220,20 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 AND acc.account_name = $4 + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + $5::BIGINT IS NULL -- noqa: RF02 + OR a.id > $5::BIGINT -- noqa: RF02 + ) ORDER BY a.id +LIMIT $6::BIGINT ` type ListAddressesByAccountParams struct { @@ -230,6 +241,8 @@ type ListAddressesByAccountParams struct { Purpose int64 CoinType int64 AccountName string + CursorID sql.NullInt64 + PageLimit int64 } type ListAddressesByAccountRow struct { @@ -246,15 +259,18 @@ type ListAddressesByAccountRow struct { HasScript bool } -// Lists all addresses for a given account identified by wallet_id, key scope -// (purpose/coin_type), and account name. Returns all address columns for -// filtering and processing by the application. +// Lists addresses for an account identified by wallet_id, key scope +// (purpose/coin_type), and account name, ordered by address ID. +// When cursor_id is provided, only rows strictly after that address ID are +// returned. Returns up to page_limit rows. func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, arg.WalletID, arg.Purpose, arg.CoinType, arg.AccountName, + arg.CursorID, + arg.PageLimit, ) if err != nil { return nil, err diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 94d35afc83..cae8eb777f 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -328,9 +328,10 @@ type Querier interface { ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) - // Lists all addresses for a given account identified by wallet_id, key scope - // (purpose/coin_type), and account name. Returns all address columns for - // filtering and processing by the application. + // Lists addresses for an account identified by wallet_id, key scope + // (purpose/coin_type), and account name, ordered by address ID. + // When cursor_id is provided, only rows strictly after that address ID are + // returned. Returns up to page_limit rows. ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index 393fdafc4d..fbf4baf2e2 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -220,9 +220,20 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ? + ks.wallet_id = ?1 + AND ks.purpose = ?2 + AND ks.coin_type = ?3 + AND acc.account_name = ?4 + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + ?5 IS NULL -- noqa: RF02 + OR a.id > ?5 -- noqa: RF02 + ) ORDER BY a.id +LIMIT ?6 ` type ListAddressesByAccountParams struct { @@ -230,6 +241,8 @@ type ListAddressesByAccountParams struct { Purpose int64 CoinType int64 AccountName string + CursorID interface{} + PageLimit int64 } type ListAddressesByAccountRow struct { @@ -246,15 +259,18 @@ type ListAddressesByAccountRow struct { HasScript bool } -// Lists all addresses for a given account identified by wallet_id, key scope -// (purpose/coin_type), and account name. Returns all address columns for -// filtering and processing by the application. +// Lists addresses for an account identified by wallet_id, key scope +// (purpose/coin_type), and account name, ordered by address ID. +// When cursor_id is provided, only rows strictly after that address ID are +// returned. Returns up to page_limit rows. func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, arg.WalletID, arg.Purpose, arg.CoinType, arg.AccountName, + arg.CursorID, + arg.PageLimit, ) if err != nil { return nil, err diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index a7345d54e8..7718ff08eb 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -324,9 +324,10 @@ type Querier interface { ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) - // Lists all addresses for a given account identified by wallet_id, key scope - // (purpose/coin_type), and account name. Returns all address columns for - // filtering and processing by the application. + // Lists addresses for an account identified by wallet_id, key scope + // (purpose/coin_type), and account name, ordered by address ID. + // When cursor_id is provided, only rows strictly after that address ID are + // returned. Returns up to page_limit rows. ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) From fe62f65d4bdf1e1ba7c3b9b8e266ee8892efdf5f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 20:31:13 -0300 Subject: [PATCH 192/695] wallet: add pagination to wallets --- wallet/internal/db/data_types.go | 6 + wallet/internal/db/interface.go | 13 +- wallet/internal/db/itest/wallet_store_test.go | 453 +++++++++++++++++- .../internal/db/queries/postgres/wallets.sql | 10 +- wallet/internal/db/queries/sqlite/wallets.sql | 10 +- wallet/internal/db/sqlc/postgres/querier.go | 5 +- .../internal/db/sqlc/postgres/wallets.sql.go | 17 +- wallet/internal/db/sqlc/sqlite/querier.go | 5 +- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 17 +- wallet/internal/db/wallet_pg.go | 95 +++- wallet/internal/db/wallet_sqlite.go | 96 +++- wallet/internal/db/wallets_common.go | 9 + 12 files changed, 665 insertions(+), 71 deletions(-) create mode 100644 wallet/internal/db/wallets_common.go diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 0563522f77..fe86cc2fcc 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -241,6 +241,12 @@ type Block struct { Timestamp time.Time } +// ListWalletsQuery contains the parameters for listing wallets. +type ListWalletsQuery struct { + // Page holds the pagination parameters for this query. + Page page.Request[uint32] +} + // CreateWalletParams contains the parameters required to create a new wallet. type CreateWalletParams struct { // Name is the name of the new wallet. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 2e838f9369..524ed32be9 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -142,10 +142,15 @@ type WalletStore interface { // error if the wallet is not found. GetWallet(ctx context.Context, name string) (*WalletInfo, error) - // ListWallets returns a slice of WalletInfo for all wallets stored in - // the database. It returns an empty slice if no wallets are found, or - // an error if the retrieval fails. - ListWallets(ctx context.Context) ([]WalletInfo, error) + // ListWallets returns one page of wallets for the given query, including a + // next-cursor for the following page. + ListWallets(ctx context.Context, query ListWalletsQuery) ( + page.Result[WalletInfo, uint32], error) + + // IterWallets returns an iterator that fetches pages transparently and + // yields wallets one by one until exhaustion or error. + IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] // UpdateWallet updates various properties of a wallet, such as its // birthday, birthday block, or sync state. The specific fields to diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 8f7f5c3624..ab7d0eee95 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -3,10 +3,12 @@ package itest import ( + "math" "testing" "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" "github.com/stretchr/testify/require" ) @@ -145,16 +147,22 @@ func TestGetWallet_NotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrWalletNotFound) } -// TestListWallets verifies that ListWallets returns all wallets. +// TestListWallets verifies that ListWallets correctly returns wallets and +// handles empty results without error. func TestListWallets(t *testing.T) { t.Parallel() store := NewTestStore(t) // Initially empty. - wallets, err := store.ListWallets(t.Context()) + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(10), + } + + pageResult, err := store.ListWallets(t.Context(), query) require.NoError(t, err) - require.Empty(t, wallets) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) // Create three wallets. names := []string{"wallet-1", "wallet-2", "wallet-3"} @@ -164,18 +172,449 @@ func TestListWallets(t *testing.T) { require.NoError(t, err) } - wallets, err = store.ListWallets(t.Context()) + pageResult, err = store.ListWallets(t.Context(), query) require.NoError(t, err) - require.Len(t, wallets, 3) + require.Len(t, pageResult.Items, 3) // Verify all names are present. - walletsName := make([]string, len(wallets)) - for i, w := range wallets { + walletsName := make([]string, len(pageResult.Items)) + for i, w := range pageResult.Items { walletsName[i] = w.Name } require.ElementsMatch(t, names, walletsName) } +// TestListWalletsPagination verifies that ListWallets paginates correctly and +// sets Next without requiring an extra round-trip. +func TestListWalletsPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Nil(t, page2.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) + + paged := append([]db.WalletInfo{}, page1.Items...) + paged = append(paged, page2.Items...) + require.Len(t, paged, len(names)) + + pagedNames := make([]string, len(paged)) + for i, wallet := range paged { + pagedNames[i] = wallet.Name + } + require.Equal(t, names, pagedNames) +} + +// TestListWalletsExactBoundary verifies that pagination correctly handles the +// exact boundary case where total results equal page-size multiples. +func TestListWalletsExactBoundary(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.Equal(t, names[0], page1.Items[0].Name) + require.Equal(t, names[1], page1.Items[1].Name) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, names[2], page2.Items[0].Name) + require.Equal(t, names[3], page2.Items[1].Name) + require.Nil(t, page2.Next) + require.Greater(t, page2.Items[0].ID, *page1.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) +} + +// TestIterWallets verifies that IterWallets exhaustively retrieves all wallets +// and yields the same results in the same order as manual cursor-based +// pagination. +func TestIterWallets(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + expected := flattenWalletPages(collectWalletPages(t, store, query)) + + iterWallets := make([]db.WalletInfo, 0, len(expected)) + for wallet, err := range store.IterWallets(t.Context(), query) { + require.NoError(t, err) + iterWallets = append(iterWallets, wallet) + } + + require.Equal(t, expected, iterWallets) +} + +// TestIterWalletsPaginated verifies that IterWallets produces the same +// results as manual pagination and correctly signals end-of-list. +func TestIterWalletsPaginated(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + pages := collectWalletPages(t, store, query) + require.Len(t, pages, 2) + require.NotNil(t, pages[0].Next) + require.Nil(t, pages[1].Next) + + expected := flattenWalletPages(pages) + + iterWallets := make([]db.WalletInfo, 0, len(expected)) + for wallet, err := range store.IterWallets(t.Context(), query) { + require.NoError(t, err) + iterWallets = append(iterWallets, wallet) + } + + require.Equal(t, expected, iterWallets) +} + +// TestListWalletsPagedFromCursor verifies that ListWallets can resume +// pagination from a specific cursor position, returning only wallets after +// that cursor. +func TestListWalletsPagedFromCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + created := make([]*db.WalletInfo, 0, len(names)) + for _, name := range names { + params := CreateWalletParamsFixture(name) + wallet, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + created = append(created, wallet) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(created[1].ID), + } + + pageResult, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, pageResult.Items, 2) + require.Equal(t, names[2], pageResult.Items[0].Name) + require.Equal(t, names[3], pageResult.Items[1].Name) + require.Nil(t, pageResult.Next) + + query.Page = query.Page.WithAfter(created[3].ID) + pageResult, err = store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) +} + +// TestListWalletsPagedWithSyncMetadata verifies that paginated wallet +// listings include sync metadata such as synced-to block and birthday block. +func TestListWalletsPagedWithSyncMetadata(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + + birthday1 := time.Now().UTC().Add(-48 * time.Hour) + birthday2 := time.Now().UTC().Add(-24 * time.Hour) + + params1 := CreateWalletParamsFixture("wallet-sync-1") + params1.Birthday = birthday1 + wallet1, err := store.CreateWallet(t.Context(), params1) + require.NoError(t, err) + + params2 := CreateWalletParamsFixture("wallet-sync-2") + params2.Birthday = birthday2 + wallet2, err := store.CreateWallet(t.Context(), params2) + require.NoError(t, err) + + block1 := CreateBlockFixture(t, queries, 100) + block2 := CreateBlockFixture(t, queries, 101) + + err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: wallet1.ID, + SyncedTo: &block2, + BirthdayBlock: &block1, + }) + require.NoError(t, err) + + err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: wallet2.ID, + SyncedTo: &block2, + BirthdayBlock: &block1, + }) + require.NoError(t, err) + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(1), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 1) + require.NotNil(t, page1.Items[0].SyncedTo) + require.NotNil(t, page1.Items[0].BirthdayBlock) + require.False(t, page1.Items[0].Birthday.IsZero()) + require.NotNil(t, page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 1) + require.NotNil(t, page2.Items[0].SyncedTo) + require.NotNil(t, page2.Items[0].BirthdayBlock) + require.False(t, page2.Items[0].Birthday.IsZero()) + require.Nil(t, page2.Next) +} + +// TestListWalletsDeterministicPagination verifies stable page ordering and +// next-cursor behavior for multi-page wallet listings. +func TestListWalletsDeterministicPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{ + "wallet-page-1", + "wallet-page-2", + "wallet-page-3", + "wallet-page-4", + "wallet-page-5", + } + + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + pages := collectWalletPages(t, store, db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + }) + require.Len(t, pages, 3) + require.Len(t, pages[0].Items, 2) + require.Len(t, pages[1].Items, 2) + require.Len(t, pages[2].Items, 1) + require.NotNil(t, pages[0].Next) + require.NotNil(t, pages[1].Next) + require.Nil(t, pages[2].Next) + + wallets := flattenWalletPages(pages) + require.Len(t, wallets, len(names)) + + collectedNames := make([]string, len(wallets)) + for i, wallet := range wallets { + if i > 0 { + require.Less(t, wallets[i-1].ID, wallet.ID) + } + + collectedNames[i] = wallet.Name + } + + require.Equal(t, names, collectedNames) + require.Equal(t, wallets[1].ID, *pages[0].Next) + require.Equal(t, wallets[3].ID, *pages[1].Next) + + seenIDs := make(map[uint32]struct{}, len(wallets)) + for i := range pages { + for j, wallet := range pages[i].Items { + _, duplicate := seenIDs[wallet.ID] + require.False(t, duplicate) + seenIDs[wallet.ID] = struct{}{} + + // Skip the first item on the first page; there's no prior cursor + // to compare against. + if i == 0 && j == 0 { + continue + } + + // First item on a later page: verify it sorts strictly after the + // previous page's cursor to ensure no gaps or duplicates at page + // boundaries. + if j == 0 { + require.Greater(t, wallet.ID, *pages[i-1].Next) + continue + } + + // Items within the same page: verify strict ordering to ensure + // the page contents are sorted. + require.Greater(t, wallet.ID, pages[i].Items[j-1].ID) + } + } +} + +// TestListWalletsInsertAfterCursor verifies inserts after page N are returned +// on page N+1 when pagination uses increasing ID cursors. +func TestListWalletsInsertAfterCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + for _, name := range []string{"wallet-a", "wallet-b", "wallet-c"} { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + + // Pagination is by increasing wallet ID (id > cursor). + // A wallet created after page 1 should therefore appear on page 2. + inserted, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture("wallet-d"), + ) + require.NoError(t, err) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, "wallet-c", page2.Items[0].Name) + require.Equal(t, inserted.ID, page2.Items[1].ID) + require.Equal(t, "wallet-d", page2.Items[1].Name) + require.Nil(t, page2.Next) +} + +// TestListWalletsCursorEdges verifies stale and zero-value cursors produce +// deterministic page results. +func TestListWalletsCursorEdges(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{"wallet-1", "wallet-2", "wallet-3"} + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(math.MaxUint32), + }) + require.NoError(t, err) + require.Empty(t, stalePage.Items) + require.Nil(t, stalePage.Next) + + zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(0), + }) + require.NoError(t, err) + require.Len(t, zeroPage.Items, 2) + require.Equal(t, names[0], zeroPage.Items[0].Name) + require.Equal(t, names[1], zeroPage.Items[1].Name) + require.NotNil(t, zeroPage.Next) +} + +// collectWalletPages collects paginated wallet results by iterating +// through all pages from ListWallets, using cursor pagination until +// Next is nil. +func collectWalletPages(t *testing.T, store db.WalletStore, + query db.ListWalletsQuery) []page.Result[db.WalletInfo, uint32] { + t.Helper() + + pages := make([]page.Result[db.WalletInfo, uint32], 0) + for { + pageResult, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + pages = append(pages, pageResult) + + if pageResult.Next == nil { + return pages + } + + query.Page = query.Page.WithAfter(*pageResult.Next) + } +} + +// flattenWalletPages flattens paginated wallet results into a single +// slice containing all wallets from all pages. +func flattenWalletPages( + pages []page.Result[db.WalletInfo, uint32]) []db.WalletInfo { + + count := 0 + for i := range pages { + count += len(pages[i].Items) + } + + wallets := make([]db.WalletInfo, 0, count) + for i := range pages { + wallets = append(wallets, pages[i].Items...) + } + + return wallets +} + // TestUpdateWallet_SyncedTo checks that updating the wallet's synced-to block // works correctly. func TestUpdateWallet_SyncedTo(t *testing.T) { diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index 58b6126a6a..e971fbbd24 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -62,6 +62,9 @@ LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.wallet_name = $1; -- name: ListWallets :many +-- Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +-- from the beginning; otherwise returns wallets with id > cursor_id. Returns up +-- to page_limit rows. SELECT w.id, w.wallet_name, @@ -80,7 +83,12 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height -ORDER BY w.id; +WHERE ( + sqlc.narg('cursor_id')::BIGINT IS NULL + OR w.id > sqlc.narg('cursor_id')::BIGINT +) +ORDER BY w.id +LIMIT sqlc.arg('page_limit')::BIGINT; -- name: GetWalletByID :one SELECT diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 3e180b39dd..a8c802e90e 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -62,6 +62,9 @@ LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.wallet_name = ?; -- name: ListWallets :many +-- Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +-- from the beginning; otherwise returns wallets with id > cursor_id. Returns up +-- to page_limit rows. SELECT w.id, w.wallet_name, @@ -80,7 +83,12 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height -ORDER BY w.id; +WHERE ( + sqlc.narg('cursor_id') IS NULL + OR w.id > sqlc.narg('cursor_id') +) +ORDER BY w.id +LIMIT sqlc.arg('page_limit'); -- name: GetWalletByID :one SELECT diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index cae8eb777f..2fc726dbc0 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -457,7 +457,10 @@ type Querier interface { // - Treats min/max confirmations as optional filters so callers can // distinguish "not set" from an explicit zero-conf request. ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) - ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Lists wallets using cursor-based pagination. If cursor_id is NULL, starts + // from the beginning; otherwise returns wallets with id > cursor_id. Returns up + // to page_limit rows. + ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) // Acquires a transaction-level advisory lock to serialize account creation within a scope. // The lock is automatically released upon transaction commit or rollback. // This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index 0aa4daccb2..91055c9f90 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -271,9 +271,19 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE ( + $1::BIGINT IS NULL + OR w.id > $1::BIGINT +) ORDER BY w.id +LIMIT $2::BIGINT ` +type ListWalletsParams struct { + CursorID sql.NullInt64 + PageLimit int64 +} + type ListWalletsRow struct { ID int64 WalletName string @@ -290,8 +300,11 @@ type ListWalletsRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { - rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) +// Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +// from the beginning; otherwise returns wallets with id > cursor_id. Returns up +// to page_limit rows. +func (q *Queries) ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets, arg.CursorID, arg.PageLimit) if err != nil { return nil, err } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 7718ff08eb..bebc1585c0 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -453,7 +453,10 @@ type Querier interface { // - Treats min/max confirmations as optional filters so callers can // distinguish "not set" from an explicit zero-conf request. ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) - ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Lists wallets using cursor-based pagination. If cursor_id is NULL, starts + // from the beginning; otherwise returns wallets with id > cursor_id. Returns up + // to page_limit rows. + ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) // Marks a wallet-owned UTXO as spent by a transaction. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 56685d4f7b..24077a7cae 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -271,9 +271,19 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE ( + ?1 IS NULL + OR w.id > ?1 +) ORDER BY w.id +LIMIT ?2 ` +type ListWalletsParams struct { + CursorID interface{} + PageLimit int64 +} + type ListWalletsRow struct { ID int64 WalletName string @@ -290,8 +300,11 @@ type ListWalletsRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { - rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) +// Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +// from the beginning; otherwise returns wallets with id > cursor_id. Returns up +// to page_limit rows. +func (q *Queries) ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets, arg.CursorID, arg.PageLimit) if err != nil { return nil, err } diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 38f1bb7ce2..d0f2277b82 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -5,7 +5,9 @@ import ( "database/sql" "errors" "fmt" + "iter" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -140,41 +142,44 @@ func (s *PostgresStore) GetWallet(ctx context.Context, }) } -// ListWallets returns a slice of WalletInfo for all wallets stored in -// the database. It returns an empty slice if no wallets are found, or -// an error if the retrieval fails. -func (s *PostgresStore) ListWallets(ctx context.Context) ([]WalletInfo, - error) { +// ListWallets returns a page of wallets matching the given query. +func (s *PostgresStore) ListWallets(ctx context.Context, + query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) if err != nil { - return nil, fmt.Errorf("list wallets: %w", err) + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: %w", err) } - wallets := make([]WalletInfo, len(rows)) + items := make([]WalletInfo, len(rows)) for i, row := range rows { - info, err := buildPgWalletInfo(pgWalletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, - }) - if err != nil { - return nil, err + item, errMap := pgListWalletRowToInfo(row) + if errMap != nil { + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: map row: %w", errMap) } - wallets[i] = *info + items[i] = *item } - return wallets, nil + result := page.BuildResult( + query.Page, items, + func(item WalletInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterWallets returns an iterator over paginated wallet results. +func (s *PostgresStore) IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + + return page.Iter( + ctx, query, s.ListWallets, nextListWalletsQuery, + ) } // UpdateWallet updates various properties of a wallet, such as its @@ -291,6 +296,44 @@ type pgWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } +// pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// struct for pagination. +func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*WalletInfo, error) { + return buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// pgListWalletsParams translates a page request to ListWallets query +// parameters, handling optional cursor setup for pagination. +func pgListWalletsParams( + req page.Request[uint32]) sqlcpg.ListWalletsParams { + + params := sqlcpg.ListWalletsParams{ + PageLimit: int64(req.QueryLimit()), + } + + if cursor, ok := req.After(); ok { + params.CursorID = sql.NullInt64{ + Int64: int64(cursor), + Valid: true, + } + } + + return params +} + // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index dae1a75308..6ce787db31 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -5,7 +5,9 @@ import ( "database/sql" "errors" "fmt" + "iter" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -140,41 +142,46 @@ func (s *SqliteStore) GetWallet(ctx context.Context, }) } -// ListWallets returns a slice of WalletInfo for all wallets stored in -// the database. It returns an empty slice if no wallets are found, or -// an error if the retrieval fails. -func (s *SqliteStore) ListWallets(ctx context.Context) ([]WalletInfo, - error) { +// ListWallets returns a page of wallets matching the given query. +func (s *SqliteStore) ListWallets(ctx context.Context, + query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets( + ctx, sqliteListWalletsParams(query.Page), + ) if err != nil { - return nil, fmt.Errorf("list wallets: %w", err) + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: %w", err) } - wallets := make([]WalletInfo, len(rows)) + items := make([]WalletInfo, len(rows)) for i, row := range rows { - info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, - }) - if err != nil { - return nil, err + item, errMap := sqliteListWalletRowToInfo(row) + if errMap != nil { + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: map row: %w", errMap) } - wallets[i] = *info + items[i] = *item } - return wallets, nil + result := page.BuildResult( + query.Page, items, + func(item WalletInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterWallets returns an iterator over paginated wallet results. +func (s *SqliteStore) IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + + return page.Iter( + ctx, query, s.ListWallets, nextListWalletsQuery, + ) } // UpdateWallet updates various properties of a wallet, such as its @@ -290,6 +297,43 @@ type sqliteWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } +// sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// struct for pagination. +func sqliteListWalletRowToInfo( + row sqlcsqlite.ListWalletsRow) (*WalletInfo, error) { + + return buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// sqliteListWalletsParams translates a page request to ListWallets query +// parameters, handling optional cursor setup for pagination. +func sqliteListWalletsParams( + req page.Request[uint32]) sqlcsqlite.ListWalletsParams { + + params := sqlcsqlite.ListWalletsParams{ + PageLimit: int64(req.QueryLimit()), + } + + if cursor, ok := req.After(); ok { + params.CursorID = int64(cursor) + } + + return params +} + // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go new file mode 100644 index 0000000000..cb3ee35be1 --- /dev/null +++ b/wallet/internal/db/wallets_common.go @@ -0,0 +1,9 @@ +package db + +// nextListWalletsQuery returns a query with its pagination cursor advanced to +// the provided value. +func nextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { + q.Page = q.Page.WithAfter(cursor) + + return q +} From 75bb607a1e79ef65da7a910dd75c8e975fda4fb7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 20:51:01 +0800 Subject: [PATCH 193/695] wallet: change `rootHashes` from map to be set --- wallet/internal/db/postgres_txstore_rollback.go | 16 +++++++--------- wallet/internal/db/sqlite_txstore_rollback.go | 16 +++++++--------- wallet/internal/db/tx_store_common.go | 11 ++++++++--- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index 769c02598d..9597b4a239 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -32,7 +32,7 @@ var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) // listRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + height uint32) (map[uint32][]chainhash.Hash, error) { rollbackHeight, err := uint32ToInt32(height) if err != nil { @@ -162,12 +162,12 @@ func (o pgRollbackToBlockOps) markDescendantsFailed( } // groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by -// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +// wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( - map[uint32]map[chainhash.Hash]struct{}, error) { + map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( - map[uint32]map[chainhash.Hash]struct{}, len(rows), + map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { walletID, err := int64ToUint32(row.WalletID) @@ -180,11 +180,9 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( return nil, fmt.Errorf("rollback coinbase hash: %w", err) } - if _, ok := rootHashesByWallet[walletID]; !ok { - rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) - } - - rootHashesByWallet[walletID][*txHash] = struct{}{} + rootHashesByWallet[walletID] = append( + rootHashesByWallet[walletID], *txHash, + ) } return rootHashesByWallet, nil diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index 90569ff840..7329b7c157 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -32,7 +32,7 @@ var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) // listRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) if err != nil { @@ -138,13 +138,13 @@ func (o sqliteRollbackToBlockOps) markDescendantsFailed( } // groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by -// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +// wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRootsSqlite( rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( - map[uint32]map[chainhash.Hash]struct{}, error) { + map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( - map[uint32]map[chainhash.Hash]struct{}, len(rows), + map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { walletID, err := int64ToUint32(row.WalletID) @@ -157,11 +157,9 @@ func groupRollbackCoinbaseRootsSqlite( return nil, fmt.Errorf("rollback coinbase hash: %w", err) } - if _, ok := rootHashesByWallet[walletID]; !ok { - rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) - } - - rootHashesByWallet[walletID][*txHash] = struct{}{} + rootHashesByWallet[walletID] = append( + rootHashesByWallet[walletID], *txHash, + ) } return rootHashesByWallet, nil diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 3717298c20..0655304330 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -580,7 +580,7 @@ type rollbackToBlockOps interface { // listRollbackRootHashes returns the coinbase roots disconnected by the // rollback, grouped by wallet for the later descendant walk. listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) + height uint32) (map[uint32][]chainhash.Hash, error) // rewindWalletSyncStateHeights clamps wallet sync-state references // below the rollback boundary before block rows are removed. @@ -706,7 +706,7 @@ func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, // invalidateRollbackDescendants clears spend edges and marks failed every // unmined descendant discovered from the provided wallet-scoped rollback roots. func invalidateRollbackDescendants(ctx context.Context, - rootHashesByWallet map[uint32]map[chainhash.Hash]struct{}, + rootHashesByWallet map[uint32][]chainhash.Hash, ops rollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { @@ -718,7 +718,12 @@ func invalidateRollbackDescendants(ctx context.Context, "wallet %d: %w", walletID, err) } - descendantIDs := collectDescendantTxIDs(rootHashes, candidates) + rootHashSet := make(map[chainhash.Hash]struct{}, len(rootHashes)) + for _, rootHash := range rootHashes { + rootHashSet[rootHash] = struct{}{} + } + + descendantIDs := collectDescendantTxIDs(rootHashSet, candidates) if len(descendantIDs) == 0 { continue } From 39238916179e075f64baf2cb9d420f70ac78e36f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 18:37:12 +0800 Subject: [PATCH 194/695] wallet: exclude direct roots from descendant walk Keep direct conflict roots out of the descendant list so callers can preserve the root replacement state while still failing only the dependent branch. --- wallet/internal/db/tx_store_common.go | 35 +++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 0655304330..67231c27dc 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -663,18 +663,22 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, return childIDs } -// collectDescendantTxIDs returns every unmined transaction that depends on any -// of the provided root hashes, including indirect descendants discovered -// through newly invalidated child hashes. -func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, - candidates []unminedTxRecord) []int64 { +// collectDescendantTxIDs returns every discovered descendant in the original +// candidate order. Any ID also listed in rootIDs is excluded so direct roots +// can keep their own state transition instead of being treated as descendants. +func collectDescendantTxIDs(rootHashes []chainhash.Hash, + rootIDs []int64, candidates []unminedTxRecord) []int64 { invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) - for hash := range rootHashes { + for _, hash := range rootHashes { invalidHashes[hash] = struct{}{} } invalidIDs := make(map[int64]struct{}, len(candidates)) + + // Walk the candidate set to a fixed point. Each time we discover one + // new descendant we add its hash to invalidHashes, which may cause + // later passes to discover txns that depend on that child. for changed := true; changed; { changed = false @@ -693,14 +697,20 @@ func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, } } - descendantIDs := make([]int64, 0, len(invalidIDs)) + // Direct roots are handled separately by the caller, so remove them here + // before the ordered descendant slice is materialized. + for _, rootID := range rootIDs { + delete(invalidIDs, rootID) + } + + orderedIDs := make([]int64, 0, len(invalidIDs)) for _, candidate := range candidates { if _, ok := invalidIDs[candidate.id]; ok { - descendantIDs = append(descendantIDs, candidate.id) + orderedIDs = append(orderedIDs, candidate.id) } } - return descendantIDs + return orderedIDs } // invalidateRollbackDescendants clears spend edges and marks failed every @@ -718,12 +728,7 @@ func invalidateRollbackDescendants(ctx context.Context, "wallet %d: %w", walletID, err) } - rootHashSet := make(map[chainhash.Hash]struct{}, len(rootHashes)) - for _, rootHash := range rootHashes { - rootHashSet[rootHash] = struct{}{} - } - - descendantIDs := collectDescendantTxIDs(rootHashSet, candidates) + descendantIDs := collectDescendantTxIDs(rootHashes, nil, candidates) if len(descendantIDs) == 0 { continue } From 2ab179505b957265b32a5c979254c2243346effc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:53:31 +0800 Subject: [PATCH 195/695] wallet: add InvalidateUnminedTx API to TxStore --- wallet/internal/db/data_types.go | 13 +++++++++++++ wallet/internal/db/interface.go | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index fe86cc2fcc..13f1cce018 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -976,6 +976,19 @@ type DeleteTxParams struct { Txid chainhash.Hash } +// InvalidateUnminedTxParams contains the parameters for invalidating one +// wallet-owned unmined transaction branch. +type InvalidateUnminedTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the unmined transaction to invalidate. + Txid chainhash.Hash +} + // -------------------- // UTXOStore Types // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 524ed32be9..c4463d2608 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -328,6 +328,17 @@ type TxStore interface { // unconfirmed-deletion path. DeleteTx(ctx context.Context, params DeleteTxParams) error + // InvalidateUnminedTx invalidates one unmined transaction branch as a + // single atomic wallet event. + // + // This method is intended for system-driven cleanup when a wallet-owned + // unmined transaction is no longer valid, for example after publisher-side + // rejection or conflict handling. Implementations must invalidate the root + // transaction and reconcile any dependent descendant state inside one + // database transaction. + InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error + // RollbackToBlock removes all blocks at and after a given height, // moving any transactions within those blocks back to the unconfirmed // pool. This operation is performed as a single, atomic database From a705fd06b95d8d9c82e6ee2fca34adb3cd4a7128 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:57:45 +0800 Subject: [PATCH 196/695] wallet: add shared unmined invalidation workflow --- .../db/tx_store_invalidateunmined_common.go | 144 ++++++++ .../tx_store_invalidateunmined_common_test.go | 343 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 wallet/internal/db/tx_store_invalidateunmined_common.go create mode 100644 wallet/internal/db/tx_store_invalidateunmined_common_test.go diff --git a/wallet/internal/db/tx_store_invalidateunmined_common.go b/wallet/internal/db/tx_store_invalidateunmined_common.go new file mode 100644 index 0000000000..1cc98cc9a0 --- /dev/null +++ b/wallet/internal/db/tx_store_invalidateunmined_common.go @@ -0,0 +1,144 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" +) + +var ( + // ErrInvalidateTx indicates that InvalidateUnminedTx rejected the requested + // tx because it is not a current active unmined row. + ErrInvalidateTx = errors.New("invalidate tx") +) + +// invalidateUnminedTxTarget is the normalized metadata the shared invalidation +// workflow needs for the root transaction. +type invalidateUnminedTxTarget struct { + // id is the backend row ID for the transaction being invalidated. + id int64 + + // txHash is the network transaction hash used for descendant discovery. + txHash chainhash.Hash + + // status is the wallet-relative state that must still be unmined. + status TxStatus + + // hasBlock reports whether the row is already confirmed in a block. + hasBlock bool + + // isCoinbase reports whether the row is a coinbase transaction. + isCoinbase bool +} + +// invalidateUnminedTxOps is the small backend adapter the shared +// InvalidateUnminedTx workflow needs. +// +// The shared invalidation algorithm is intentionally ordered: +// - load and validate the requested root transaction first +// - load the active unmined graph snapshot used for descendant discovery +// - discover every descendant that depends on the root before any mutation +// starts +// - clear wallet-owned spent-input edges for the root and every discovered +// descendant +// - mark the full invalidated branch failed in one batch update +// +// This sequencing keeps the invalidation event atomic and prevents a partially +// invalid branch from retaining wallet-owned spend edges if any later step +// fails. The backend adapters only supply query wiring and row-shape +// conversions. +type invalidateUnminedTxOps interface { + // loadInvalidateTarget loads the wallet-scoped root tx metadata. + loadInvalidateTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) ( + invalidateUnminedTxTarget, error) + + // listUnminedTxRecords loads the wallet's active unmined transaction rows + // in the normalized shape the descendant walk expects. + listUnminedTxRecords(ctx context.Context, walletID int64) ( + []unminedTxRecord, error) + + // clearSpentUtxos restores any wallet-owned parent outputs spent by the + // given transaction row. + clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error + + // markTxnsFailed batch-marks the provided tx rows as failed. + markTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error +} + +// validateUnminedTxTarget checks that the requested root is a current +// unmined non-coinbase transaction. +func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { + if target.hasBlock { + return fmt.Errorf("tx %s is confirmed: %w", target.txHash, + ErrInvalidateTx) + } + + if target.isCoinbase { + return fmt.Errorf("tx %s is coinbase: %w", target.txHash, + ErrInvalidateTx) + } + + if !isUnminedStatus(target.status) { + return fmt.Errorf("tx %s has status %d: %w", target.txHash, + target.status, ErrInvalidateTx) + } + + return nil +} + +// invalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction +// root together with any descendant branch that depends on it. +// +// The helper performs descendant discovery before any spend-edge or status +// mutation begins. It then clears the root spend, clears descendant spends, and +// finally marks the combined branch failed. Keeping that ordering in one shared +// helper ensures postgres and sqlite invalidate branches with identical wallet +// semantics. +func invalidateUnminedTxWithOps(ctx context.Context, + params InvalidateUnminedTxParams, ops invalidateUnminedTxOps) error { + + target, err := ops.loadInvalidateTarget(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("load invalidate tx target: %w", err) + } + + err = validateUnminedTxTarget(target) + if err != nil { + return err + } + + candidates, err := ops.listUnminedTxRecords(ctx, int64(params.WalletID)) + if err != nil { + return fmt.Errorf("list unmined invalidation txns: %w", err) + } + + descendantIDs := collectDescendantTxIDs( + []chainhash.Hash{target.txHash}, nil, candidates, + ) + + err = ops.clearSpentUtxos(ctx, int64(params.WalletID), target.id) + if err != nil { + return fmt.Errorf("clear root spent utxos: %w", err) + } + + for _, descendantID := range descendantIDs { + err = ops.clearSpentUtxos(ctx, int64(params.WalletID), descendantID) + if err != nil { + return fmt.Errorf("clear descendant spent utxos: %w", err) + } + } + + failedIDs := make([]int64, 0, len(descendantIDs)+1) + failedIDs = append(failedIDs, target.id) + failedIDs = append(failedIDs, descendantIDs...) + + err = ops.markTxnsFailed(ctx, int64(params.WalletID), failedIDs) + if err != nil { + return fmt.Errorf("mark invalidated txns failed: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/tx_store_invalidateunmined_common_test.go b/wallet/internal/db/tx_store_invalidateunmined_common_test.go new file mode 100644 index 0000000000..73f6759f40 --- /dev/null +++ b/wallet/internal/db/tx_store_invalidateunmined_common_test.go @@ -0,0 +1,343 @@ +package db + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + errInvalidateCommonTest = errors.New("invalidate common test") + + errInvalidateMockLoadInvalidateTargetType = errors.New( + "loadInvalidateTarget result is not invalidateUnminedTxTarget", + ) + + errInvalidateMockListUnminedType = errors.New( + "listUnminedTxRecords result is not []unminedTxRecord", + ) +) + +// mockInvalidateUnminedTxOps is a mock implementation of +// invalidateUnminedTxOps. +type mockInvalidateUnminedTxOps struct { + mock.Mock +} + +// loadInvalidateTarget implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, + txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + args := m.Called(ctx, walletID, txHash) + if args.Get(0) == nil { + return invalidateUnminedTxTarget{}, args.Error(1) + } + + target, ok := args.Get(0).(invalidateUnminedTxTarget) + if !ok { + return invalidateUnminedTxTarget{}, + errInvalidateMockLoadInvalidateTargetType + } + + return target, args.Error(1) +} + +// listUnminedTxRecords implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) { + + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + records, ok := args.Get(0).([]unminedTxRecord) + if !ok { + return nil, errInvalidateMockListUnminedType + } + + return records, args.Error(1) +} + +// clearSpentUtxos implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + args := m.Called(ctx, walletID, txID) + + return args.Error(0) +} + +// markTxnsFailed implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) markTxnsFailed(ctx context.Context, + walletID int64, txIDs []int64) error { + + args := m.Called(ctx, walletID, txIDs) + + return args.Error(0) +} + +// TestValidateInvalidateUnminedTxTarget verifies the root-state validation for +// InvalidateUnminedTx. +func TestValidateInvalidateUnminedTxTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target invalidateUnminedTxTarget + wantErr error + }{ + { + name: "pending root", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{1}, + status: TxStatusPending, + hasBlock: false, + }, + }, + { + name: "published root", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{2}, + status: TxStatusPublished, + hasBlock: false, + }, + }, + { + name: "confirmed root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{3}, + status: TxStatusPublished, + hasBlock: true, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "failed root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{4}, + status: TxStatusFailed, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "coinbase root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{5}, + status: TxStatusPublished, + isCoinbase: true, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "orphaned root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{6}, + status: TxStatusOrphaned, + }, + wantErr: ErrInvalidateTx, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateUnminedTxTarget(test.target) + require.ErrorIs(t, err, test.wantErr) + }) + } +} + +// TestInvalidateUnminedTxWithOps verifies the shared invalidation workflow for +// one unmined root and its descendants. +func TestInvalidateUnminedTxWithOps(t *testing.T) { + t.Parallel() + + rootHash := chainhash.Hash{1} + childHash := chainhash.Hash{2} + grandchildHash := chainhash.Hash{3} + + candidates := []unminedTxRecord{{ + id: 2, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, + }}}, + }, { + id: 3, + hash: grandchildHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, + }}}, + }} + + var ( + cleared []int64 + failedIDs []int64 + ) + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( + invalidateUnminedTxTarget{ + id: 1, + txHash: rootHash, + status: TxStatusPublished, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( + nil).Run(func(args mock.Arguments) { + txIDs, ok := args.Get(2).([]int64) + require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) + }).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{WalletID: 7, Txid: rootHash}, + ops, + ) + require.NoError(t, err) + + // The root spend is cleared first, then each discovered descendant spend is + // cleared before the whole branch is marked failed in one batch update. + require.Equal(t, []int64{1, 2, 3}, cleared) + require.Equal(t, []int64{1, 2, 3}, failedIDs) +} + +// TestInvalidateUnminedTxWithOpsNoDescendants verifies that the shared helper +// still clears and fails the root when no dependent branch exists. +func TestInvalidateUnminedTxWithOpsNoDescendants(t *testing.T) { + t.Parallel() + + var ( + cleared []int64 + failedIDs []int64 + ) + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{9}, + ).Return( + invalidateUnminedTxTarget{ + id: 4, + txHash: chainhash.Hash{9}, + status: TxStatusPending, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("markTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( + nil).Run(func(args mock.Arguments) { + txIDs, ok := args.Get(2).([]int64) + require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) + }).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{WalletID: 8, Txid: chainhash.Hash{9}}, + ops, + ) + require.NoError(t, err) + require.Equal(t, []int64{4}, cleared) + require.Equal(t, []int64{4}, failedIDs) +} + +// TestInvalidateUnminedTxWithOpsErrors verifies that the shared helper returns +// load and graph-discovery errors before mutating any rows. +func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { + t.Parallel() + + t.Run("load target", func(t *testing.T) { + t.Parallel() + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{1}).Return( + nil, errInvalidateCommonTest).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{ + WalletID: 8, + Txid: chainhash.Hash{1}, + }, + ops, + ) + require.ErrorIs(t, err, errInvalidateCommonTest) + require.ErrorContains(t, err, "load invalidate tx target") + }) + + t.Run("list descendants", func(t *testing.T) { + t.Parallel() + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{2}).Return( + invalidateUnminedTxTarget{ + id: 5, + txHash: chainhash.Hash{2}, + status: TxStatusPublished, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + nil, errInvalidateCommonTest).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{ + WalletID: 8, + Txid: chainhash.Hash{2}}, + ops, + ) + require.ErrorIs(t, err, errInvalidateCommonTest) + require.ErrorContains(t, err, "list unmined invalidation txns") + }) +} From 2a617ce91817146dde0fbefc99a4ebb338ce2085 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:59:25 +0800 Subject: [PATCH 197/695] wallet: add postgres and sqlite InvalidateUnminedTx backends --- .../db/postgres_txstore_invalidateunmined.go | 123 +++++++++++++++++ .../db/sqlite_txstore_invalidateunmined.go | 125 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_invalidateunmined.go create mode 100644 wallet/internal/db/sqlite_txstore_invalidateunmined.go diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/postgres_txstore_invalidateunmined.go new file mode 100644 index 0000000000..20d3b34818 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_invalidateunmined.go @@ -0,0 +1,123 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// InvalidateUnminedTx atomically invalidates one wallet-owned unmined +// transaction branch and marks the root plus descendants failed. +func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return invalidateUnminedTxWithOps( + ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, + ) + }) +} + +// pgInvalidateUnminedTxOps adapts postgres sqlc queries to the shared +// InvalidateUnminedTx workflow. +type pgInvalidateUnminedTxOps struct { + qtx *sqlcpg.Queries +} + +var _ invalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) + +// loadInvalidateTarget loads the root tx metadata used by the shared +// invalidation workflow. +func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + row, err := o.qtx.GetTransactionMetaByHash( + ctx, sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + ErrTxNotFound) + } + + return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + err) + } + + status, err := parseTxStatus(int64(row.TxStatus)) + if err != nil { + return invalidateUnminedTxTarget{}, err + } + + return invalidateUnminedTxTarget{ + id: row.ID, + txHash: txHash, + status: status, + hasBlock: row.BlockHeight.Valid, + isCoinbase: row.IsCoinbase, + }, nil +} + +// listUnminedTxRecords loads and decodes the wallet's active unmined +// transaction rows. +func (o pgInvalidateUnminedTxOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// transaction row. +func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: txID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + return nil +} + +// markTxnsFailed marks the provided tx rows failed in one +// batch update. +func (o pgInvalidateUnminedTxOps) markTxnsFailed( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusFailed), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns failed: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite_txstore_invalidateunmined.go new file mode 100644 index 0000000000..2403ab5f64 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_invalidateunmined.go @@ -0,0 +1,125 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// InvalidateUnminedTx atomically invalidates one wallet-owned unmined +// transaction branch and marks the root plus descendants failed. +func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return invalidateUnminedTxWithOps( + ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, + ) + }) +} + +// sqliteInvalidateUnminedTxOps adapts sqlite sqlc queries to the shared +// InvalidateUnminedTx workflow. +type sqliteInvalidateUnminedTxOps struct { + qtx *sqlcsqlite.Queries +} + +var _ invalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) + +// loadInvalidateTarget loads the root tx metadata used by the shared +// invalidation workflow. +func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + row, err := o.qtx.GetTransactionMetaByHash( + ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + ErrTxNotFound) + } + + return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + err) + } + + status, err := parseTxStatus(row.TxStatus) + if err != nil { + return invalidateUnminedTxTarget{}, err + } + + return invalidateUnminedTxTarget{ + id: row.ID, + txHash: txHash, + status: status, + hasBlock: row.BlockHeight.Valid, + isCoinbase: row.IsCoinbase, + }, nil +} + +// listUnminedTxRecords loads and decodes the wallet's active unmined +// transaction rows. +func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords( + rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( + int64, []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// transaction row. +func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: txID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + return nil +} + +// markTxnsFailed marks the provided tx rows failed in one +// batch update. +func (o sqliteInvalidateUnminedTxOps) markTxnsFailed( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusFailed), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns failed: %w", err) + } + + return nil +} From 188211ec1338c8d0bdd6c9024b074d85c1386a0f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 14:55:29 +0800 Subject: [PATCH 198/695] wallet: embed invalidation ops in CreateTx adapters Embed the backend InvalidateUnminedTx adapters directly in the CreateTx backend structs so the later conflict-handling hooks can reuse the same query wiring. This keeps the backend composition explicit before the shared CreateTx flow starts depending on the promoted invalidation methods. --- wallet/internal/db/postgres_txstore_createtx.go | 8 ++++++-- wallet/internal/db/sqlite_txstore_createtx.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index 370f61d14c..3c82110400 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -28,13 +28,17 @@ func (s *PostgresStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return createTxWithOps(ctx, req, &pgCreateTxOps{qtx: qtx}) + return createTxWithOps(ctx, req, &pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: qtx, + }, + }) }) } // pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. type pgCreateTxOps struct { - qtx *sqlcpg.Queries + pgInvalidateUnminedTxOps blockHeight sql.NullInt32 } diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 18d6013b35..65206c669f 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -28,13 +28,17 @@ func (s *SqliteStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return createTxWithOps(ctx, req, &sqliteCreateTxOps{qtx: qtx}) + return createTxWithOps(ctx, req, &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: qtx, + }, + }) }) } // sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. type sqliteCreateTxOps struct { - qtx *sqlcsqlite.Queries + sqliteInvalidateUnminedTxOps blockHeight sql.NullInt64 } From 7bfdaf503c9224e913a352498ceb754fe2e31a5f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 14:57:25 +0800 Subject: [PATCH 199/695] wallet: add CreateTx conflict backend hooks Add the backend helpers that discover direct CreateTx conflict roots and record replacement state for the winning confirmed tx. Keeping these postgres and sqlite details together makes the later shared CreateTx flow change depend on one additive backend step. --- .../internal/db/postgres_txstore_createtx.go | 133 ++++++++++++++++++ wallet/internal/db/sqlite_txstore_createtx.go | 128 +++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index 3c82110400..88bbd50400 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -88,6 +88,97 @@ func (o *pgCreateTxOps) prepareBlock(ctx context.Context, return nil } +// listConflictTxns returns the direct conflict root IDs plus the matching tx +// hashes used for descendant discovery. +func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { + + rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) + if err != nil { + return nil, nil, err + } + + if len(rootIDs) == 0 { + return nil, nil, nil + } + + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + if err != nil { + return nil, nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildPgConflictRoots(rows, rootIDs) +} + +// collectPgConflictRootIDs returns the active unmined spender row IDs +// that currently own any wallet-controlled input spent by the incoming tx. +func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, + req createTxRequest) (map[int64]struct{}, error) { + + if blockchain.IsCoinBaseTx(req.params.Tx) { + return map[int64]struct{}{}, nil + } + + rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) + for inputIndex, txIn := range req.params.Tx.TxIn { + outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + if err != nil { + return nil, fmt.Errorf("convert input outpoint index %d: %w", + inputIndex, err) + } + + spentByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(req.params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + + return nil, fmt.Errorf("lookup input conflict %d: %w", inputIndex, + err) + } + + if !spentByTxID.Valid { + continue + } + + rootIDs[spentByTxID.Int64] = struct{}{} + } + + return rootIDs, nil +} + +// buildPgConflictRoots maps the selected unmined rows into ordered root IDs and +// the matching root hashes used for descendant discovery. +func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, + rootIDSet map[int64]struct{}) ( + []int64, []chainhash.Hash, error) { + + rootIDs := make([]int64, 0, len(rootIDSet)) + + rootHashes := make([]chainhash.Hash, 0, len(rootIDSet)) + for _, row := range rows { + if _, ok := rootIDSet[row.ID]; !ok { + continue + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, nil, fmt.Errorf("tx hash: %w", err) + } + + rootIDs = append(rootIDs, row.ID) + rootHashes = append(rootHashes, *txHash) + } + + return rootIDs, rootHashes, nil +} + // insert stores one new postgres transaction row for CreateTx. func (o *pgCreateTxOps) insert(ctx context.Context, req createTxRequest) (int64, error) { @@ -123,6 +214,48 @@ func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, return markInputsSpentPg(ctx, o.qtx, req.params, txID) } +// markTxnsReplaced marks the provided direct conflict roots replaced in one +// batch update. +func (o *pgCreateTxOps) markTxnsReplaced( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusReplaced), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns replaced: %w", err) + } + + return nil +} + +// insertReplacementEdges records replacement-history edges from each direct +// conflict root to the newly inserted confirmed transaction row. +func (o *pgCreateTxOps) insertReplacementEdges( + ctx context.Context, walletID int64, replacedTxIDs []int64, + replacementTxID int64) error { + + for _, replacedTxID := range replacedTxIDs { + _, err := o.qtx.InsertTxReplacementEdge( + ctx, sqlcpg.InsertTxReplacementEdgeParams{ + WalletID: walletID, + ReplacedTxID: replacedTxID, + ReplacementTxID: replacementTxID, + }, + ) + if err != nil { + return fmt.Errorf("insert replacement edge for %d: %w", + replacedTxID, err) + } + } + + return nil +} + // insertCreditsPg inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 65206c669f..aa14ad5d06 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -90,6 +90,92 @@ func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, return nil } +// listConflictTxns returns the direct conflict root IDs plus the matching tx +// hashes used for descendant discovery. +func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { + + rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) + if err != nil { + return nil, nil, err + } + + if len(rootIDs) == 0 { + return nil, nil, nil + } + + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + if err != nil { + return nil, nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildSqliteConflictRoots(rows, rootIDs) +} + +// collectSqliteConflictRootIDs returns the active unmined spender row +// IDs that currently own any wallet-controlled input spent by the incoming tx. +func collectSqliteConflictRootIDs(ctx context.Context, + qtx *sqlcsqlite.Queries, + req createTxRequest) (map[int64]struct{}, error) { + + if blockchain.IsCoinBaseTx(req.params.Tx) { + return map[int64]struct{}{}, nil + } + + rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) + for inputIndex, txIn := range req.params.Tx.TxIn { + spentByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(req.params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: int64(txIn.PreviousOutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + + return nil, fmt.Errorf("lookup input conflict %d: %w", inputIndex, + err) + } + + if !spentByTxID.Valid { + continue + } + + rootIDs[spentByTxID.Int64] = struct{}{} + } + + return rootIDs, nil +} + +// buildSqliteConflictRoots maps the selected unmined rows into ordered root IDs +// and the matching root hashes used for descendant discovery. +func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, + rootIDSet map[int64]struct{}) ( + []int64, []chainhash.Hash, error) { + + rootIDs := make([]int64, 0, len(rootIDSet)) + + rootHashes := make([]chainhash.Hash, 0, len(rootIDSet)) + for _, row := range rows { + if _, ok := rootIDSet[row.ID]; !ok { + continue + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, nil, fmt.Errorf("tx hash: %w", err) + } + + rootIDs = append(rootIDs, row.ID) + rootHashes = append(rootHashes, *txHash) + } + + return rootIDs, rootHashes, nil +} + // insert stores one new sqlite transaction row for CreateTx. func (o *sqliteCreateTxOps) insert(ctx context.Context, req createTxRequest) (int64, error) { @@ -128,6 +214,48 @@ func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) } +// markTxnsReplaced marks the provided direct conflict roots replaced in one +// batch update. +func (o *sqliteCreateTxOps) markTxnsReplaced( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusReplaced), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns replaced: %w", err) + } + + return nil +} + +// insertReplacementEdges records replacement-history edges from each direct +// conflict root to the newly inserted confirmed transaction row. +func (o *sqliteCreateTxOps) insertReplacementEdges( + ctx context.Context, walletID int64, replacedTxIDs []int64, + replacementTxID int64) error { + + for _, replacedTxID := range replacedTxIDs { + _, err := o.qtx.InsertTxReplacementEdge( + ctx, sqlcsqlite.InsertTxReplacementEdgeParams{ + WalletID: walletID, + ReplacedTxID: replacedTxID, + ReplacementTxID: replacementTxID, + }, + ) + if err != nil { + return fmt.Errorf("insert replacement edge for %d: %w", + replacedTxID, err) + } + } + + return nil +} + // insertCreditsSqlite inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, From 342d9b51476a7e97591ee6912fe48e724f438797 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:49:49 +0800 Subject: [PATCH 200/695] wallet: reuse existing CreateTx rows Teach the shared CreateTx flow to reload an existing wallet-scoped tx row and promote that row when the same unmined tx later arrives with confirming block metadata. This prepares the existing-row reuse path before the later conflict handling changes extend the shared CreateTx flow further. --- .../internal/db/postgres_txstore_createtx.go | 66 ++++++++++--- wallet/internal/db/sqlite_txstore_createtx.go | 66 ++++++++++--- wallet/internal/db/tx_store_common.go | 93 +++++++++++++++++-- wallet/internal/db/tx_store_common_test.go | 60 ++++++++++-- 4 files changed, 238 insertions(+), 47 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index 88bbd50400..a22b40ed1d 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -15,10 +15,11 @@ import ( // wallet-owned credits, and any spend edges created by its inputs. // // The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// and spent-parent markers are either committed together or not at all. -// Received timestamps are normalized to UTC before insert. CreateTx is -// insert-only and returns ErrTxAlreadyExists if the wallet already stores the -// tx hash. +// spent-parent markers, and any required invalidation are either committed +// together or not at all. Received timestamps are normalized to UTC before +// insert. When the wallet already stores the same unmined transaction hash, +// CreateTx may promote that existing row to confirmed state instead of +// inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, params CreateTxParams) error { @@ -45,26 +46,65 @@ type pgCreateTxOps struct { var _ createTxOps = (*pgCreateTxOps)(nil) -// hasExisting reports whether the wallet already stores the requested tx hash. -func (o *pgCreateTxOps) hasExisting(ctx context.Context, - req createTxRequest) (bool, error) { +// loadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *pgCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { - _, err := o.qtx.GetTransactionMetaByHash( + meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ WalletID: int64(req.params.WalletID), TxHash: req.txHash[:], }, ) - if err == nil { - return true, nil + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errCreateTxExistingNotFound + } + + return nil, fmt.Errorf("get tx metadata: %w", err) } - if errors.Is(err, sql.ErrNoRows) { - return false, nil + status, err := parseTxStatus(int64(meta.TxStatus)) + if err != nil { + return nil, err } - return false, fmt.Errorf("get tx metadata: %w", err) + return &createTxExistingTarget{ + id: meta.ID, + status: status, + hasBlock: meta.BlockHeight.Valid, + isCoinbase: meta.IsCoinbase, + }, nil +} + +// confirmExisting promotes one existing unmined row to its confirmed state. +func (o *pgCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, + _ createTxExistingTarget) error { + + blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, + Status: int16(TxStatusPublished), + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + } + + return nil } // prepareBlock validates the optional confirming block and caches the postgres diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index aa14ad5d06..2b6aa5d56f 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -15,10 +15,11 @@ import ( // credits, and any spend edges created by its inputs. // // The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// and spent-parent markers are either committed together or not at all. -// Received timestamps are normalized to UTC before insert. CreateTx is -// insert-only and returns ErrTxAlreadyExists if the wallet already stores the -// tx hash. +// spent-parent markers, and any required invalidation are either committed +// together or not at all. Received timestamps are normalized to UTC before +// insert. When the wallet already stores the same unmined transaction hash, +// CreateTx may promote that existing row to confirmed state instead of +// inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, params CreateTxParams) error { @@ -45,28 +46,65 @@ type sqliteCreateTxOps struct { var _ createTxOps = (*sqliteCreateTxOps)(nil) -// hasExisting reports whether the wallet already stores the requested tx hash. -func (o *sqliteCreateTxOps) hasExisting(ctx context.Context, - req createTxRequest) (bool, error) { +// loadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *sqliteCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { - _, err := o.qtx.GetTransactionMetaByHash( + meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ WalletID: int64(req.params.WalletID), TxHash: req.txHash[:], }, ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errCreateTxExistingNotFound + } + + return nil, fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(meta.TxStatus) + if err != nil { + return nil, err + } + + return &createTxExistingTarget{ + id: meta.ID, + status: status, + hasBlock: meta.BlockHeight.Valid, + isCoinbase: meta.IsCoinbase, + }, nil +} + +// confirmExisting promotes one existing unmined row to its confirmed state. +func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, + _ createTxExistingTarget) error { - // Exit early if there is no error. - if err == nil { - return true, nil + blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) } - if errors.Is(err, sql.ErrNoRows) { - return false, nil + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, + Status: int64(TxStatusPublished), + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) } - return false, fmt.Errorf("get tx metadata: %w", err) + return nil } // prepareBlock validates the optional confirming block and caches the sqlite diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 67231c27dc..e38010ced8 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -256,6 +256,18 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { }, nil } +// createTxExistingTarget is the normalized metadata the shared CreateTx flow +// needs when the wallet already stores the requested tx hash. +type createTxExistingTarget struct { + id int64 + status TxStatus + hasBlock bool + isCoinbase bool +} + +var errCreateTxExistingNotFound = errors.New("create tx existing target not " + + "found") + // createTxOps is the small semantic adapter CreateTx needs from one SQL // backend. // @@ -265,8 +277,6 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { // inserting a duplicate // - validate and cache any confirming block metadata before later writes use // it -// - when the incoming tx is confirmed, discover and invalidate any direct -// conflict roots before the new row claims their wallet-owned inputs // - insert the base transaction row exactly once when no existing row can be // reused // - insert every wallet-owned credited output as a UTXO @@ -278,9 +288,15 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { // creation, and spent-input claims must either all observe the same tx row or // all roll back together. type createTxOps interface { - // hasExisting reports whether CreateTx would collide with an existing - // wallet-scoped transaction row for the same hash. - hasExisting(ctx context.Context, req createTxRequest) (bool, error) + // loadExisting loads any existing wallet-scoped transaction row for the + // same hash. + loadExisting(ctx context.Context, req createTxRequest) ( + *createTxExistingTarget, error) + + // confirmExisting reuses one existing row when CreateTx learns about the + // same transaction with confirming block context later. + confirmExisting(ctx context.Context, req createTxRequest, + existing createTxExistingTarget) error // prepareBlock validates and caches any optional confirming block metadata // the later insert step needs. @@ -298,6 +314,55 @@ type createTxOps interface { markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error } +// checkReuseCreateTx reports whether CreateTx should reuse an existing wallet- +// scoped row instead of inserting a new one. +func checkReuseCreateTx(req createTxRequest, + existing createTxExistingTarget) bool { + + if req.params.Block == nil { + return false + } + + if req.params.Status != TxStatusPublished { + return false + } + + if existing.hasBlock { + return false + } + + if existing.isCoinbase { + return false + } + + if !isUnminedStatus(existing.status) { + return false + } + + return true +} + +// loadCreateTxExisting resolves any wallet-scoped row already stored for the +// requested tx hash and reports whether one was found. +func loadCreateTxExisting(ctx context.Context, req createTxRequest, + ops createTxOps) (*createTxExistingTarget, bool, error) { + + existing, err := ops.loadExisting(ctx, req) + if err != nil && !errors.Is(err, errCreateTxExistingNotFound) { + return nil, false, fmt.Errorf("load create tx target: %w", err) + } + + if errors.Is(err, errCreateTxExistingNotFound) { + return nil, false, nil + } + + if existing == nil { + return nil, false, nil + } + + return existing, true, nil +} + // createTxWithOps runs the backend-independent CreateTx orchestration once the // caller has opened a backend-specific SQL transaction. // @@ -307,14 +372,22 @@ type createTxOps interface { func createTxWithOps(ctx context.Context, req createTxRequest, ops createTxOps) error { - exists, err := ops.hasExisting(ctx, req) + existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) if err != nil { - return fmt.Errorf("prepare create tx: check existing tx: %w", err) + return err } - if exists { - return fmt.Errorf("prepare create tx: tx %s: %w", req.txHash, - ErrTxAlreadyExists) + if foundExisting { + if !checkReuseCreateTx(req, *existing) { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + } + + err = ops.confirmExisting(ctx, req, *existing) + if err != nil { + return fmt.Errorf("confirm existing tx: %w", err) + } + + return nil } err = ops.prepareBlock(ctx, req) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 27d01538c5..14a357df69 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -328,7 +328,7 @@ func TestCreateTxWithOpsInsert(t *testing.T) { // Assert: The shared flow executes the expected write sequence. require.Equal(t, - []string{"exists", "prepare-block", "insert", "credits", "inputs"}, + []string{"load-existing", "prepare-block", "insert", "credits", "inputs"}, ops.calls, ) require.Equal(t, int64(11), ops.creditsTxID) @@ -342,11 +342,38 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { t.Parallel() req := testCreateTxRequest(t) - ops := &stubCreateTxOps{hasExistingResult: true} + ops := &stubCreateTxOps{existing: &createTxExistingTarget{id: 4}} err := createTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) - require.Equal(t, []string{"exists"}, ops.calls) + require.Equal(t, []string{"load-existing"}, ops.calls) +} + +// TestCreateTxWithOpsConfirmExisting verifies that the shared CreateTx flow can +// promote one existing unmined row to confirmed state instead of inserting a +// duplicate row. +func TestCreateTxWithOpsConfirmExisting(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{existing: &createTxExistingTarget{ + id: 7, + status: TxStatusPending, + hasBlock: false, + }} + + err = createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } // testBlock builds a simple block fixture for CreateTx validation tests. @@ -382,8 +409,8 @@ func testCoinbaseMsgTx() *wire.MsgTx { // stubCreateTxOps records how the shared CreateTx helper drives one backend // adapter while letting each test control the returned transaction IDs. type stubCreateTxOps struct { - hasExistingResult bool - insertTxID int64 + existing *createTxExistingTarget + insertTxID int64 calls []string insertReq createTxRequest @@ -393,14 +420,27 @@ type stubCreateTxOps struct { var _ createTxOps = (*stubCreateTxOps)(nil) -// hasExisting records that the shared flow checked whether the tx hash already +// loadExisting records that the shared flow checked whether the tx hash already // exists and returns the test-controlled result. -func (s *stubCreateTxOps) hasExisting(_ context.Context, - _ createTxRequest) (bool, error) { +func (s *stubCreateTxOps) loadExisting(_ context.Context, + _ createTxRequest) (*createTxExistingTarget, error) { + + s.calls = append(s.calls, "load-existing") - s.calls = append(s.calls, "exists") + if s.existing == nil { + return nil, errCreateTxExistingNotFound + } - return s.hasExistingResult, nil + return s.existing, nil +} + +// confirmExisting records that the shared flow promoted one existing row. +func (s *stubCreateTxOps) confirmExisting(_ context.Context, + _ createTxRequest, _ createTxExistingTarget) error { + + s.calls = append(s.calls, "confirm-existing") + + return nil } // prepareBlock records that the shared flow validated any optional block From 6d9b78725e97a4e80dd0ba661ef7a0479f0dc390 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:50:11 +0800 Subject: [PATCH 201/695] wallet: handle CreateTx conflicts in shared flow Extend the shared CreateTx flow to discover direct conflict roots, rewrite those roots to replaced state, and fail any dependent branch before the new winner claims the shared inputs. This commit keeps the shared conflict orchestration and its unit tests together so the replacement ordering stays reviewable in one place. --- wallet/internal/db/tx_store_common.go | 231 +++++++++++- wallet/internal/db/tx_store_common_test.go | 393 ++++++++++++++++++++- 2 files changed, 592 insertions(+), 32 deletions(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index e38010ced8..ad4a1d7898 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -279,6 +279,11 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // it // - insert the base transaction row exactly once when no existing row can be // reused +// - when the incoming tx is confirmed, discover any direct conflict roots +// before later writes claim shared inputs or rewrite that branch +// - if direct conflicts were found, rewrite those roots to replaced state, +// fail their descendants, and record replacement history before the new +// row claims their wallet-owned inputs // - insert every wallet-owned credited output as a UTXO // - attach any wallet-owned spent inputs to that final transaction row // @@ -288,6 +293,8 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // creation, and spent-input claims must either all observe the same tx row or // all roll back together. type createTxOps interface { + invalidateUnminedTxOps + // loadExisting loads any existing wallet-scoped transaction row for the // same hash. loadExisting(ctx context.Context, req createTxRequest) ( @@ -302,6 +309,20 @@ type createTxOps interface { // the later insert step needs. prepareBlock(ctx context.Context, req createTxRequest) error + // listConflictTxns returns the direct wallet-owned conflict tx IDs plus the + // corresponding hashes used for descendant discovery. + listConflictTxns(ctx context.Context, req createTxRequest) ([]int64, + []chainhash.Hash, error) + + // markTxnsReplaced batch-marks the provided direct conflict roots + // as replaced. + markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error + + // insertReplacementEdges records replacement-history edges from each direct + // conflict root to the newly inserted confirmed transaction row. + insertReplacementEdges(ctx context.Context, walletID int64, + replacedTxIDs []int64, replacementTxID int64) error + // insert writes the base transaction row and returns its new primary key. insert(ctx context.Context, req createTxRequest) (int64, error) @@ -319,22 +340,34 @@ type createTxOps interface { func checkReuseCreateTx(req createTxRequest, existing createTxExistingTarget) bool { + // Only a newly confirmed observation can reuse an existing row. + // Plain unmined inserts still create fresh unmined history + // instead of rewriting one existing record in place. if req.params.Block == nil { return false } + // Reuse is only for the mined published state that records + // the wallet's final view of the tx once a block anchors it. if req.params.Status != TxStatusPublished { return false } + // A row that already has a confirming block is already in its final mined + // form, so CreateTx must reject the duplicate instead of mutating it again. if existing.hasBlock { return false } + // Coinbase rows only reuse the orphaned state. That path restores the same + // coinbase hash after rollback disconnected its previous confirming block. if existing.isCoinbase { return false } + // Non-coinbase rows only reuse the current unmined states. + // Once a row is invalidated, UpdateTx/DeleteTx no longer + // treat it as a live unmined target. if !isUnminedStatus(existing.status) { return false } @@ -363,48 +396,174 @@ func loadCreateTxExisting(ctx context.Context, req createTxRequest, return existing, true, nil } -// createTxWithOps runs the backend-independent CreateTx orchestration once the -// caller has opened a backend-specific SQL transaction. +// collectConflictDescendants loads the live unmined graph snapshot and returns +// the descendant tx IDs for the provided direct conflict roots. // -// The helper inserts the base transaction row before it records credits or -// spent inputs so every later step can point at one stable row ID. If any later -// step fails, ExecuteTx rolls the whole write back. -func createTxWithOps(ctx context.Context, req createTxRequest, - ops createTxOps) error { +// NOTE: rootHashes is expected to be a set with unique tx hashes. +func collectConflictDescendants(ctx context.Context, walletID int64, + rootHashes []chainhash.Hash, rootIDs []int64, + ops createTxOps) ([]int64, error) { - existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) + candidates, err := ops.listUnminedTxRecords(ctx, walletID) if err != nil { - return err + return nil, fmt.Errorf("list create tx conflict candidates: %w", err) } - if foundExisting { - if !checkReuseCreateTx(req, *existing) { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + descendantIDs := collectDescendantTxIDs(rootHashes, rootIDs, candidates) + + return descendantIDs, nil +} + +// handleRootTxns clears the direct root spends, marks those rows replaced, and +// records replacement edges to the winning confirmed tx. +func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, + replacementTxID int64, ops createTxOps) error { + + for _, rootID := range rootIDs { + err := ops.clearSpentUtxos(ctx, walletID, rootID) + if err != nil { + return fmt.Errorf("clear replaced root spent utxos: %w", err) } + } - err = ops.confirmExisting(ctx, req, *existing) + err := ops.markTxnsReplaced(ctx, walletID, rootIDs) + if err != nil { + return fmt.Errorf("mark direct conflicts replaced: %w", err) + } + + err = ops.insertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) + if err != nil { + return fmt.Errorf("record conflict replacement edges: %w", err) + } + + return nil +} + +// handleTxDescendants clears the discovered descendant spends and then marks +// that dependent branch failed. +func handleTxDescendants(ctx context.Context, walletID int64, + descendantIDs []int64, ops createTxOps) error { + + if len(descendantIDs) == 0 { + return nil + } + + for _, descendantID := range descendantIDs { + err := ops.clearSpentUtxos(ctx, walletID, descendantID) if err != nil { - return fmt.Errorf("confirm existing tx: %w", err) + return fmt.Errorf("clear failed descendant spent utxos: %w", err) } + } + err := ops.markTxnsFailed(ctx, walletID, descendantIDs) + if err != nil { + return fmt.Errorf("mark conflict descendants failed: %w", err) + } + + return nil +} + +// handleTxConflicts discovers the direct conflicting roots of a new confirmed +// tx, rewrites them to replaced state, and marks their descendants failed. +// +// The replacement algorithm is intentionally ordered: +// - load the direct conflicting roots first +// - load the live unmined graph snapshot used for descendant discovery +// - discover every descendant that depends on those roots before any mutation +// starts +// - handle the direct root txns first +// - handle the descendant txns second +// +// That sequencing preserves replacement history for the direct conflicts while +// still invalidating the dependent branch atomically inside one SQL +// transaction. +func handleTxConflicts(ctx context.Context, req createTxRequest, + replacementTxID int64, ops createTxOps) error { + + // Only confirmed inserts can replace an active unmined branch. + if req.params.Block == nil { return nil } - err = ops.prepareBlock(ctx, req) + // Load the direct roots first so every later step works from the currently + // visible spend edges on the shared parent inputs. + rootIDs, rootHashes, err := ops.listConflictTxns(ctx, req) if err != nil { - return fmt.Errorf("prepare create block assignment: %w", err) + return fmt.Errorf("list conflict txns: %w", err) } + // Exit early if there are no conflicts. + if len(rootIDs) == 0 { + return nil + } + + walletID := int64(req.params.WalletID) + + // Discover descendants before any mutation starts. + // Later rewrites can otherwise hide part of the displaced + // branch from the graph walk. + descendantIDs, err := collectConflictDescendants( + ctx, walletID, rootHashes, rootIDs, ops, + ) + if err != nil { + return err + } + + // Direct roots keep the replacement state and replacement history. + err = handleRootTxns(ctx, walletID, rootIDs, replacementTxID, ops) + if err != nil { + return err + } + + // Descendants clear their spend edges and then fall to the failed state. + err = handleTxDescendants(ctx, walletID, descendantIDs, ops) + if err != nil { + return err + } + + return nil +} + +// insertCreateTx completes the fresh-insert CreateTx path. +// +// The order is important: +// - insert first so the new winner row has a stable tx ID +// - reconcile conflicts next while the displaced unmined branch is still +// discoverable through the current spend edges +// - create wallet-owned credits after replacement handling +// - claim wallet-owned parent inputs last, because that rewires the shared +// spend edges to the new winner row +func insertCreateTx(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + // Insert the winner row first so every later write can point at its stable + // primary key. In particular, replacement-history edges need the new tx ID. txID, err := ops.insert(ctx, req) if err != nil { return fmt.Errorf("insert tx: %w", err) } + // Reconcile any conflicting unmined branch before this tx claims the shared + // parent inputs. Conflict discovery looks at the current spend edges on + // those parents; once markInputsSpent rewires them to this new row, the old + // branch is no longer discoverable as the direct conflict root. + err = handleTxConflicts(ctx, req, txID, ops) + if err != nil { + return err + } + + // Credits only describe outputs created by the new tx itself, so they do + // not interfere with conflict discovery. Keep them after replacement + // handling so the branch rewrite stays grouped with the shared-input + // reconciliation. err = ops.insertCredits(ctx, req, txID) if err != nil { return fmt.Errorf("create tx credits: %w", err) } + // Claim wallet-owned parent inputs last. This is the write that makes the + // new tx the recorded spender of the shared parents, so doing it earlier + // would hide the displaced unmined branch from the replacement walk. err = ops.markInputsSpent(ctx, req, txID) if err != nil { return fmt.Errorf("create tx spends: %w", err) @@ -413,6 +572,42 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } +// createTxWithOps runs the backend-independent CreateTx orchestration once the +// caller has opened a backend-specific SQL transaction. +// +// The helper can either confirm an existing unmined row or insert a new row. +// For confirmed inserts it also reconciles any current direct conflict branch +// before the new row claims wallet-owned inputs. The helper owns that ordering +// so the backends only need to supply query wiring and type conversion. +func createTxWithOps(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) + if err != nil { + return err + } + + if foundExisting { + if !checkReuseCreateTx(req, *existing) { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + } + + err = ops.confirmExisting(ctx, req, *existing) + if err != nil { + return fmt.Errorf("confirm existing tx: %w", err) + } + + return nil + } + + err = ops.prepareBlock(ctx, req) + if err != nil { + return fmt.Errorf("prepare create block assignment: %w", err) + } + + return insertCreateTx(ctx, req, ops) +} + // validateUpdateTxParams checks that UpdateTx received at least one mutable // field and that any requested state transition satisfies the transaction table // invariants. @@ -508,9 +703,7 @@ type updateTxOps interface { func updateTxWithOps(ctx context.Context, params UpdateTxParams, ops updateTxOps) error { - isCoinbase, err := ops.loadIsCoinbase( - ctx, params.WalletID, params.Txid, - ) + isCoinbase, err := ops.loadIsCoinbase(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load update tx target: %w", err) } diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 14a357df69..a6c2775e62 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -3,6 +3,7 @@ package db import ( "bytes" "context" + "errors" "testing" "time" @@ -328,7 +329,13 @@ func TestCreateTxWithOpsInsert(t *testing.T) { // Assert: The shared flow executes the expected write sequence. require.Equal(t, - []string{"load-existing", "prepare-block", "insert", "credits", "inputs"}, + []string{ + "load-existing", + "prepare-block", + "insert", + "credits", + "inputs", + }, ops.calls, ) require.Equal(t, int64(11), ops.creditsTxID) @@ -376,6 +383,45 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } +// TestCreateTxWithOpsReplaceConflicts verifies that the shared CreateTx flow +// rewrites direct conflict roots to replaced state after inserting the +// confirmed winner and before that winner claims the shared spent-parent edge. +func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + insertTxID: 11, + conflictIDs: []int64{5}, + conflictHashes: []chainhash.Hash{{9}}, + } + + err = createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "load-existing", + "prepare-block", + "insert", + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "credits", + "inputs", + }, ops.calls) + require.Equal(t, int64(11), ops.replacedConflictTxID) +} + // testBlock builds a simple block fixture for CreateTx validation tests. func testBlock(height uint32) *Block { return &Block{ @@ -409,13 +455,26 @@ func testCoinbaseMsgTx() *wire.MsgTx { // stubCreateTxOps records how the shared CreateTx helper drives one backend // adapter while letting each test control the returned transaction IDs. type stubCreateTxOps struct { - existing *createTxExistingTarget - insertTxID int64 - - calls []string - insertReq createTxRequest - creditsTxID int64 - inputsTxID int64 + existing *createTxExistingTarget + insertTxID int64 + conflictIDs []int64 + conflictHashes []chainhash.Hash + unminedTxns []unminedTxRecord + + listConflictsErr error + listUnminedErr error + clearSpentErr error + markFailedErr error + + calls []string + insertReq createTxRequest + creditsTxID int64 + inputsTxID int64 + clearedTxIDs []int64 + replacedTxIDs []int64 + failedTxIDs []int64 + replacementEdgeTxIDs []int64 + replacedConflictTxID int64 } var _ createTxOps = (*stubCreateTxOps)(nil) @@ -427,16 +486,13 @@ func (s *stubCreateTxOps) loadExisting(_ context.Context, s.calls = append(s.calls, "load-existing") - if s.existing == nil { - return nil, errCreateTxExistingNotFound - } - return s.existing, nil } // confirmExisting records that the shared flow promoted one existing row. func (s *stubCreateTxOps) confirmExisting(_ context.Context, - _ createTxRequest, _ createTxExistingTarget) error { + _ createTxRequest, + _ createTxExistingTarget) error { s.calls = append(s.calls, "confirm-existing") @@ -453,6 +509,95 @@ func (s *stubCreateTxOps) prepareBlock(_ context.Context, return nil } +// listConflictTxns records that the shared flow checked for direct wallet-owned +// conflicts after insert and before input claims. +func (s *stubCreateTxOps) listConflictTxns(_ context.Context, + _ createTxRequest) ([]int64, []chainhash.Hash, error) { + + s.calls = append(s.calls, "list-conflicts") + + if s.listConflictsErr != nil { + return nil, nil, s.listConflictsErr + } + + return s.conflictIDs, s.conflictHashes, nil +} + +// loadInvalidateTarget satisfies the embedded invalidateUnminedTxOps interface +// for the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) loadInvalidateTarget(_ context.Context, _ uint32, + _ chainhash.Hash) (invalidateUnminedTxTarget, error) { + + return invalidateUnminedTxTarget{}, nil +} + +// listUnminedTxRecords satisfies the embedded invalidateUnminedTxOps interface +// for the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) listUnminedTxRecords(_ context.Context, + _ int64) ([]unminedTxRecord, error) { + + s.calls = append(s.calls, "list-unmined-records") + + if s.listUnminedErr != nil { + return nil, s.listUnminedErr + } + + return s.unminedTxns, nil +} + +// clearSpentUtxos satisfies the embedded invalidateUnminedTxOps interface for +// the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) clearSpentUtxos(_ context.Context, _ int64, + txID int64) error { + + s.calls = append(s.calls, "clear-spent-utxos") + s.clearedTxIDs = append(s.clearedTxIDs, txID) + + if s.clearSpentErr != nil { + return s.clearSpentErr + } + + return nil +} + +// markTxnsFailed satisfies the embedded invalidateUnminedTxOps interface for +// the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) markTxnsFailed(_ context.Context, _ int64, + txIDs []int64) error { + + s.calls = append(s.calls, "mark-txns-failed") + s.failedTxIDs = append([]int64(nil), txIDs...) + + if s.markFailedErr != nil { + return s.markFailedErr + } + + return nil +} + +// markTxnsReplaced satisfies the replacement hooks CreateTx uses for direct +// conflict reconciliation. +func (s *stubCreateTxOps) markTxnsReplaced(_ context.Context, _ int64, + txIDs []int64) error { + + s.calls = append(s.calls, "mark-txns-replaced") + s.replacedTxIDs = append([]int64(nil), txIDs...) + + return nil +} + +// insertReplacementEdges satisfies the replacement hooks CreateTx uses for +// direct conflict reconciliation. +func (s *stubCreateTxOps) insertReplacementEdges(_ context.Context, _ int64, + replacedTxIDs []int64, replacementTxID int64) error { + + s.calls = append(s.calls, "insert-replacement-edges") + s.replacementEdgeTxIDs = append([]int64(nil), replacedTxIDs...) + s.replacedConflictTxID = replacementTxID + + return nil +} + // insert records the request that the shared flow would store as a fresh // transaction row. func (s *stubCreateTxOps) insert(_ context.Context, @@ -503,6 +648,228 @@ func testCreateTxRequest(t *testing.T) createTxRequest { return req } +var errConflictMarkFailed = errors.New("mark failed") + +// TestCollectConflictDescendants verifies that the helper derives the direct +// root IDs and descendant IDs from the current unmined graph snapshot. +func TestCollectConflictDescendants(t *testing.T) { + t.Parallel() + + candidates := []unminedTxRecord{{ + id: 12, + hash: chainhash.Hash{2}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }}}, + }, { + id: 13, + hash: chainhash.Hash{3}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, + }}}, + }} + ops := &stubCreateTxOps{unminedTxns: candidates} + + rootIDs := []int64{11, 12} + rootHashes := []chainhash.Hash{{1}, {2}} + + descendantIDs, err := collectConflictDescendants( + context.Background(), 7, rootHashes, rootIDs, ops, + ) + require.NoError(t, err) + require.Equal(t, []int64{13}, descendantIDs) + require.Equal(t, []string{"list-unmined-records"}, ops.calls) +} + +// TestHandleTxConflicts verifies the shared replacement flow for +// one direct conflict root and its dependent descendants. +func TestHandleTxConflicts(t *testing.T) { + t.Parallel() + + rootHash := chainhash.Hash{1} + childHash := chainhash.Hash{2} + grandchildHash := chainhash.Hash{3} + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + candidates := []unminedTxRecord{{ + id: 2, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, + }}}, + }, { + id: 3, + hash: grandchildHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, + }}}, + }} + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{rootHash}, + unminedTxns: candidates, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) + require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) + require.Equal(t, []int64{1}, ops.replacedTxIDs) + require.Equal(t, []int64{2, 3}, ops.failedTxIDs) + require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) + require.Equal(t, int64(9), ops.replacedConflictTxID) +} + +// TestHandleTxConflictsKeepsDirectRootsReplaced verifies that a +// direct conflict root stays replaced even when it also spends another direct +// root and would otherwise appear in the descendant walk. +func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { + t.Parallel() + + rootAHash := chainhash.Hash{1} + rootBHash := chainhash.Hash{2} + childHash := chainhash.Hash{3} + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1, 2}, + conflictHashes: []chainhash.Hash{rootAHash, rootBHash}, + unminedTxns: []unminedTxRecord{{ + id: 2, + hash: rootBHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootAHash, Index: 0}, + }}}, + }, { + id: 3, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, + }}}, + }}, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) + require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) + require.Equal(t, []int64{1, 2}, ops.replacedTxIDs) + require.Equal(t, []int64{3}, ops.failedTxIDs) + require.Equal(t, []int64{1, 2}, ops.replacementEdgeTxIDs) +} + +// TestHandleTxConflictsNoDescendants verifies that the helper +// skips the descendant-failure batch when no dependent branch exists. +func TestHandleTxConflictsNoDescendants(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{{1}}, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + }, ops.calls) + require.Equal(t, []int64{1}, ops.clearedTxIDs) + require.Equal(t, []int64{1}, ops.replacedTxIDs) + require.Empty(t, ops.failedTxIDs) + require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) + require.Equal(t, int64(9), ops.replacedConflictTxID) +} + +// TestHandleTxConflictsMarkFailedError verifies that the helper records +// replacement edges before returning descendant failure errors. +func TestHandleTxConflictsMarkFailedError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{{1}}, + unminedTxns: []unminedTxRecord{{ + id: 2, + hash: chainhash.Hash{2}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }}}, + }}, + markFailedErr: errConflictMarkFailed, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.ErrorIs(t, err, errConflictMarkFailed) + require.ErrorContains(t, err, "mark conflict descendants failed") + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) +} + // TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow // can apply both a label patch and a state patch in one atomic sequence. func TestUpdateTxWithOpsLabelAndState(t *testing.T) { From baf618ac9d158643bd0d5a33670de808ff61e334 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:50:24 +0800 Subject: [PATCH 202/695] wallet: add CreateTx conflict itests Add integration coverage for the CreateTx conflict and reconfirmation paths so both SQL backends exercise the shared replacement flow against real database state. --- .../internal/db/itest/tx_utxo_store_test.go | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 1b854d77d1..d858d0f77b 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -302,6 +302,207 @@ func TestCreateTxRejectsDuplicateTx(t *testing.T) { require.True(t, ok) } +// TestCreateTxConfirmsExistingUnminedRow verifies that CreateTx reuses one live +// unmined row when the same transaction later arrives with confirming block +// metadata. +// +// Scenario: +// - One wallet already stores the transaction in its unmined history. +// +// Setup: +// - Create one wallet-owned credited transaction in pending state. +// - Create one matching confirming block fixture for the same transaction +// hash. +// +// Action: +// - Call CreateTx again with the same transaction hash and confirming block. +// +// Assertions: +// - The existing row remains stored once. +// - The transaction becomes confirmed and published. +// - The existing label remains unchanged. +func TestCreateTxConfirmsExistingUnminedRow(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirm-existing-unmined") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 250) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000500, 0), + Status: db.TxStatusPending, + Label: "seed", + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000600, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Label: "ignored", + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, info.Block) + require.Equal(t, confirmedBlock.Height, info.Block.Height) + require.Equal(t, db.TxStatusPublished, info.Status) + require.Equal(t, "seed", info.Label) + + unminedTxs, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }) + require.NoError(t, err) + require.Empty(t, unminedTxs) + + confirmedTxs, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: confirmedBlock.Height, + EndHeight: confirmedBlock.Height, + }) + require.NoError(t, err) + require.Len(t, confirmedTxs, 1) + require.Equal(t, tx.TxHash(), confirmedTxs[0].Hash) +} + +// TestCreateTxConfirmedWinnerInvalidatesConflictBranch verifies that a newly +// confirmed transaction invalidates the conflicting unmined branch before it +// claims the shared wallet-owned input. +// +// Scenario: +// - One wallet-owned output already has one unmined spend chain. +// - A confirmed conflicting transaction later arrives for the same outpoint. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Insert one unmined child and one unmined grandchild depending on it. +// - Build one confirmed conflicting transaction spending the same parent +// outpoint. +// +// Action: +// - Insert the confirmed conflicting transaction through CreateTx. +// +// Assertions: +// - The direct conflicting root becomes replaced. +// - The descendant row becomes failed. +// - The confirmed winner is stored. +// - The parent outpoint remains spent instead of returning to the UTXO set. +func TestCreateTxConfirmedWinnerInvalidatesConflictBranch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirmed-conflict-winner") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + firstChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: firstChild, + Received: time.Unix(1710001510, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + grandchild := newRegularTx( + []wire.OutPoint{{Hash: firstChild.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: grandchild, + Received: time.Unix(1710001520, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 260) + confirmedWinner := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 3500, PkScript: []byte{0x53}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedWinner, + Received: time.Unix(1710001530, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: firstChild.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, childInfo.Block) + require.Equal(t, db.TxStatusReplaced, childInfo.Status) + + grandchildInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: grandchild.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, grandchildInfo.Block) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + + winnerInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: confirmedWinner.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, winnerInfo.Block) + require.Equal(t, db.TxStatusPublished, winnerInfo.Status) + + _, err = store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: spentOutPoint, + }) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + // TestGetTxReturnsStoredPendingTx verifies that GetTx rebuilds the public // transaction view for one stored unmined row. // From 2eaa5cec7130852d98615b493fffb772c5c7a3c2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 23:19:17 +0800 Subject: [PATCH 203/695] wallet: integrate orphan handling into RollbackToBlock --- .../internal/db/itest/tx_utxo_store_test.go | 87 +++++++++++++++++++ .../internal/db/postgres_txstore_rollback.go | 30 +++++++ wallet/internal/db/sqlite_txstore_rollback.go | 31 +++++++ wallet/internal/db/tx_store_common.go | 32 ++++++- 4 files changed, 179 insertions(+), 1 deletion(-) diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index d858d0f77b..91e15f3731 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1280,6 +1280,7 @@ func TestDeleteTxRemovesParentWithFailedChild(t *testing.T) { // - Roll back the block that confirmed the coinbase root. // // Assertions: +// - The disconnected coinbase root becomes orphaned. // - Both unmined descendants become failed. // - The spend edges from the coinbase root and child are cleared. func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { @@ -1338,6 +1339,14 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { err = store.RollbackToBlock(t.Context(), block.Height) require.NoError(t, err) + coinbaseInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusOrphaned, coinbaseInfo.Status) + require.Nil(t, coinbaseInfo.Block) + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, Txid: childTx.TxHash(), @@ -1358,6 +1367,84 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) } +// TestCreateTxReconfirmsOrphanedCoinbase verifies that CreateTx can restore an +// orphaned coinbase row to confirmed history when the same coinbase hash later +// re-enters the best chain. +// +// Scenario: +// - One wallet already stores one orphaned coinbase transaction after +// rollback. +// +// Setup: +// - Create and confirm one wallet-owned coinbase transaction. +// - Roll back the confirming block so the coinbase becomes orphaned. +// - Create one new confirming block for the same tx hash. +// +// Action: +// - Call CreateTx again with the same coinbase transaction and new block. +// +// Assertions: +// - The existing row becomes confirmed and published again. +// - The wallet-owned coinbase output returns to the current UTXO set. +func TestCreateTxReconfirmsOrphanedCoinbase(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-reconfirm-orphaned-coinbase") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + block := CreateBlockFixture(t, queries, 310) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001540, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.RollbackToBlock(t.Context(), block.Height) + require.NoError(t, err) + + orphanInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusOrphaned, orphanInfo.Status) + require.Nil(t, orphanInfo.Block) + + newBlock := CreateBlockFixture(t, queries, 311) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001550, 0), + Block: &newBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusPublished, info.Status) + require.NotNil(t, info.Block) + require.Equal(t, newBlock.Height, info.Block.Height) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: coinbaseTx.TxHash(), Index: 0, + })) +} + // TestGetUtxoReturnsCurrentWalletOutput verifies that GetUtxo returns a stored // wallet-owned output created by an unmined transaction. func TestGetUtxoReturnsCurrentWalletOutput(t *testing.T) { diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index 9597b4a239..f4b9b2ec4b 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -104,6 +104,36 @@ func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } +// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// orphaned state once its confirming block has been deleted. +func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, + walletID uint32, rootHashes []chainhash.Hash) error { + + for _, txHash := range rootHashes { + // Rollback already removed the confirming block rows. + // The remaining coinbase row must therefore clear its + // block reference and become orphaned in the same + // row-local state patch. + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt32{}, + Status: int16(TxStatusOrphaned), + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update rollback coinbase state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + } + + return nil +} + // listUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o pgRollbackToBlockOps) listUnminedTxRecords( diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index 7329b7c157..42ec51be17 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -78,6 +78,37 @@ func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } +// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// orphaned state once its confirming block has been deleted. +func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( + ctx context.Context, walletID uint32, + rootHashes []chainhash.Hash) error { + + for _, txHash := range rootHashes { + // Rollback already removed the confirming block rows. + // The remaining coinbase row must therefore clear its + // block reference and become orphaned in the same + // row-local state patch. + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt64{}, + Status: int64(TxStatusOrphaned), + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update rollback coinbase state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + } + + return nil +} + // listUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o sqliteRollbackToBlockOps) listUnminedTxRecords( diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index ad4a1d7898..134ee3f456 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -362,7 +362,10 @@ func checkReuseCreateTx(req createTxRequest, // Coinbase rows only reuse the orphaned state. That path restores the same // coinbase hash after rollback disconnected its previous confirming block. if existing.isCoinbase { - return false + // Both sides must still be coinbase history, and the existing + // row must be the rollback-created orphan that is waiting for a + // confirming block again. + return req.isCoinbase && existing.status == TxStatusOrphaned } // Non-coinbase rows only reuse the current unmined states. @@ -856,6 +859,11 @@ type rollbackToBlockOps interface { // rollback boundary after sync-state references have been rewound. deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + // markTxRootsOrphaned rewrites the disconnected coinbase roots to the + // orphaned state after their confirming blocks are deleted. + markTxRootsOrphaned(ctx context.Context, walletID uint32, + rootHashes []chainhash.Hash) error + // listUnminedTxRecords loads the wallet's current unmined transaction // rows in the normalized shape the descendant walk expects. listUnminedTxRecords(ctx context.Context, @@ -1017,6 +1025,23 @@ func invalidateRollbackDescendants(ctx context.Context, return nil } +// markTxRootsOrphaned rewrites every disconnected coinbase root to the +// orphaned state before descendant invalidation completes. +func markTxRootsOrphaned(ctx context.Context, + rootHashesByWallet map[uint32][]chainhash.Hash, + ops rollbackToBlockOps) error { + + for walletID, rootHashes := range rootHashesByWallet { + err := ops.markTxRootsOrphaned(ctx, walletID, rootHashes) + if err != nil { + return fmt.Errorf("mark rollback coinbase roots orphaned for "+ + "wallet %d: %w", walletID, err) + } + } + + return nil +} + // rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one // backend-specific SQL transaction. // @@ -1041,6 +1066,11 @@ func rollbackToBlockWithOps(ctx context.Context, height uint32, return fmt.Errorf("delete blocks at or above height: %w", err) } + err = markTxRootsOrphaned(ctx, rootHashesByWallet, ops) + if err != nil { + return err + } + err = invalidateRollbackDescendants(ctx, rootHashesByWallet, ops) if err != nil { return err From 4376c7b89f97bff043bef3796f8c4d333310bea0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 23:23:12 +0800 Subject: [PATCH 204/695] docs: add tx invalidation flow guide --- docs/developer/README.md | 11 +- docs/developer/adr/0006-wtxmgr-sql-schema.md | 2 +- docs/developer/tx_invalidation_flows.md | 164 +++++++++++++++++++ docs/developer/utxo_data_model.md | 2 + 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 docs/developer/tx_invalidation_flows.md diff --git a/docs/developer/README.md b/docs/developer/README.md index 38ed43f839..4551496f42 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -28,6 +28,15 @@ Best practices for writing clear, effective, and maintainable unit tests. --- +## 🧾 Transaction Invalidation Flows + +A focused guide to the wallet tx-store event model for transaction invalidation, +publisher cleanup, and rollback handling. + +**[➡️ Read the Transaction Invalidation Flows Guide](./tx_invalidation_flows.md)** + +--- + ## 🏛️ Engineering Guide A deep dive into the core design philosophy, architectural patterns, and Go implementation details that guide the development of `btcwallet`. @@ -48,4 +57,4 @@ Formal documentation of significant architectural decisions, their context, and A detailed guide to creating Bitcoin transactions using the `PsbtManager` interface, covering various scenarios and best practices. -**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** \ No newline at end of file +**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md index 9fbf0c924b..f2e4516eee 100644 --- a/docs/developer/adr/0006-wtxmgr-sql-schema.md +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -48,7 +48,7 @@ Two designs were considered for tracking transaction validity: * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. * **Derived status from other columns (alternative):** - * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. + * At coarse granularity (pending vs active vs invalid), some states are partly derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; direct `replaced` victims can be identified from `tx_replacements.replaced_tx_id`. Direct `failed` victims are not derivable from replacement edges alone because upstream invalidation intentionally records no replacement edge. * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `tx_status` column. diff --git a/docs/developer/tx_invalidation_flows.md b/docs/developer/tx_invalidation_flows.md new file mode 100644 index 0000000000..c0efe9a015 --- /dev/null +++ b/docs/developer/tx_invalidation_flows.md @@ -0,0 +1,164 @@ +# Transaction Invalidation Flows + +This document defines how the SQL wallet store applies invalidation-related +write workflows when one wallet event changes tx history. It complements +[Wallet Data Model and Lifecycle](./utxo_data_model.md), which defines the +stored states, and +[ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md), +which defines the schema and its invariants. + +It focuses on the workflow phases, the invariants each phase must preserve, +and the backend guarantees that follow from that model. + +## 1. Wallet Events and Scope + +From the wallet's point of view, a small set of events can change tx history: + +- tx ingest, e.g. when the wallet learns about a newly created or newly + confirmed tx. +- row-local metadata patching, e.g. when `UpdateTx` changes a label or + block/status fields without rewriting graph edges. +- invalidation of an unmined branch, e.g. when publisher-side cleanup fails + one local spend and its dependent descendants. +- rollback of confirmed history at a block boundary, e.g. when a reorg + disconnects blocks and rewinds formerly confirmed wallet history. + +The first two events place invalidation in the broader tx-store model. The +branch-mutating workflows described here are the ones that discover dependent +descendants, clear now-invalid spend edges, rewrite history, and commit the +whole result atomically. + +## 2. Terminology + +- **Root:** The first tx row directly affected by the wallet event. +- **Descendant:** An unmined tx that spends an output created by a root or by a + later discovered descendant. +- **Branch:** One root plus every descendant discovered from that root set. +- **Spend edge:** The wallet-owned relationship that records which tx spends a + previously created output. + +## 3. Core Invariants + +Every invalidation or rollback workflow must preserve the same wallet-visible +invariants. + +- **Atomicity:** Each wallet event executes in one database transaction. The + store must not commit a partially invalidated branch or a partially applied + rollback. +- **No dangling spend edges:** If one tx becomes invalid, the store must clear + any spend references that would otherwise keep invalid UTXO relationships + alive. +- **Retained invalid history:** Invalid, replaced, failed, or orphaned rows + remain part of the wallet's historical view. The workflow rewrites state; it + does not erase audit history. +- **Event-owned graph mutation:** Row-local patching must stay row-local. + Descendant traversal, spend-edge cleanup, replacement tracking, and rollback + orphaning belong only to the workflows that own those mutations. +- **Event-specific root states:** Different wallet events may drive the same + branch through different root-state outcomes. A direct conflict root may + become `replaced`, while a descendant invalidated by that same event becomes + `failed`. + +## 4. Workflow Phases + +When one branch-mutating event runs, the store follows the same overall phases. + +### 4.1 Discover roots + +The workflow first identifies the root rows directly affected by the wallet +event. Those roots may come from the tx being invalidated, from direct conflict +rows discovered during confirmed ingest, or from coinbase rows disconnected by +rollback. + +### 4.2 Snapshot candidate txns + +Before mutation starts, the workflow loads the current unmined tx set needed +for descendant discovery. This snapshot must happen first so later state +rewrites do not hide part of the branch that still needs cleanup. + +### 4.3 Discover descendants + +The workflow then walks the unmined candidate set to a fixed point. Each newly +discovered descendant expands the invalid parent set, which may reveal later +txns farther down the branch. + +### 4.4 Clear spend edges + +Before any affected row becomes visibly invalid, the workflow clears the +wallet-owned spend edges claimed by that root or descendant set. This prevents +the store from exposing rewritten rows that still appear to spend outputs from +an invalid branch. + +### 4.5 Rewrite roots + +After the root set is fully known and its spend edges are safe to rewrite, the +workflow applies the event-specific root outcome. The root state depends on the +wallet event, not just on the graph shape. + +### 4.6 Rewrite descendants + +Once the root outcome is fixed, the workflow rewrites dependent descendants to +their derived invalid state. Direct roots and descendants do not always land in +the same state, so they are handled as separate phases. + +### 4.7 Commit atomically + +Either every phase above commits together or none of them does. The workflow +must not leave behind half-rewritten states, partially cleared spend edges, or +only part of the affected branch updated. + +## 5. Event Outcomes + +The workflow phases stay the same across events, but the resulting root state +depends on which wallet event started the flow. + +| Wallet event | Root outcome | Descendant outcome | Example | +| --- | --- | --- | --- | +| `InvalidateUnminedTx` | `failed` | `failed` | Publisher-side cleanup rejects one local unmined branch. | +| `CreateTx` conflict handling | direct conflict roots become `replaced` | dependent descendants become `failed` | A newly confirmed winner claims wallet-owned inputs already spent by an unmined branch. | +| `RollbackToBlock` | disconnected coinbase roots become `orphaned` | dependent descendants become `failed` | A reorg disconnects the confirming block for the root branch. | + +Row-local metadata patching does not enter this branch workflow because it does +not discover descendants or rewrite spend edges. + +## 6. Worked Example + +Consider one branch with three txns: + +- `A` is the root tx. +- `B` spends an output created by `A`. +- `C` spends an output created by `B`. + +The same branch can be rewritten differently depending on the initiating event: + +- Under `InvalidateUnminedTx(A)`, `A`, `B`, and `C` all become `failed`. +- Under confirmed conflict handling against `A`, `A` becomes `replaced` while + `B` and `C` become `failed`. +- Under rollback that disconnects confirmed coinbase `A`, `A` becomes + `orphaned` while `B` and `C` become `failed`. + +In every case, descendant discovery happens before mutation and spend-edge +cleanup happens before the rewritten states become visible. + +## 7. Backend Guarantees + +Postgres and sqlite may differ internally in query bindings, row types, and +helper structure. They must still preserve the same workflow guarantees. + +- The same wallet event yields the same final tx states. +- The same descendant branch is invalidated for the same root set. +- Spend-edge cleanup happens before invalid state becomes visible. +- The whole event remains all-or-nothing at the SQL transaction boundary. + +These guarantees keep the SQL stores aligned with the legacy `kvdb` backend at +the event level, even when the SQL stores retain richer explicit invalid- +history states internally. + +## 8. Relationship to Other Docs + +- [`wallet/internal/db/interface.go`](../../wallet/internal/db/interface.go) + describes the caller-facing `TxStore` contract. +- [Wallet Data Model and Lifecycle](./utxo_data_model.md) explains the + persisted states and the "retain history" policy. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md) + defines the schema-level invariants that these workflows must preserve. diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md index 5e99026ef2..19c43d90fa 100644 --- a/docs/developer/utxo_data_model.md +++ b/docs/developer/utxo_data_model.md @@ -2,6 +2,8 @@ This document defines the core data model, state machines, and design philosophy of the `btcwallet` transaction subsystem. It serves as the definitive reference for understanding how the wallet manages money, history, and state. +For the exact write-path rules that apply transaction ingress, invalidation, and rollback to the stored graph, see [Transaction Invalidation Flows](./tx_invalidation_flows.md). + ## 1. Design Philosophy The `btcwallet` architecture is built upon a specific worldview: **The wallet is a UTXO Manager.** From 0efacaa3c7db861ba84665dac15a5a90fd375ef4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 11:52:23 +0800 Subject: [PATCH 205/695] wallet: rename `transaction` to `tx` --- wallet/internal/db/interface.go | 4 ++-- wallet/internal/db/tx_store_common.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index c4463d2608..017b49d02a 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -76,11 +76,11 @@ var ( // ErrTxNotFound is returned when a transaction is not found in the // database. - ErrTxNotFound = errors.New("transaction not found") + ErrTxNotFound = errors.New("tx not found") // ErrTxAlreadyExists is returned when CreateTx is asked to insert a // wallet-scoped transaction hash that already exists. - ErrTxAlreadyExists = errors.New("transaction already exists") + ErrTxAlreadyExists = errors.New("tx already exists") // ErrBlockNotFound is returned when a transaction operation references a // block height that does not exist in the shared blocks table. diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 134ee3f456..1418f5ca3c 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -18,7 +18,7 @@ var ( // ErrInvalidStatus is returned when a transaction status is unknown or not // allowed for the requested operation. - ErrInvalidStatus = errors.New("invalid transaction status") + ErrInvalidStatus = errors.New("invalid tx status") // ErrIndexOutOfRange is returned when a referenced transaction input or // output index does not exist. @@ -36,7 +36,7 @@ var ( // ErrDeleteRequiresLeaf indicates that DeleteTx only accepts unmined // transactions with no child spenders. - ErrDeleteRequiresLeaf = errors.New("delete requires a leaf transaction") + ErrDeleteRequiresLeaf = errors.New("delete requires a leaf tx") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the From eff9772211ee25e157d0a0ea361606b0ed138323 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 11:53:45 +0800 Subject: [PATCH 206/695] wallet: keep UpdateTx row-local Reject failed, replaced, and orphaned state patches through UpdateTx so callers cannot invalidate a tx branch without reconciling dependent state. This keeps UpdateTx focused on row-local metadata changes and makes the event-shaped APIs responsible for graph-affecting lifecycle updates. --- wallet/internal/db/interface.go | 6 +- wallet/internal/db/tx_store_common.go | 23 ++++---- wallet/internal/db/tx_store_common_test.go | 64 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 017b49d02a..4be4dfd46d 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -299,8 +299,10 @@ type TxStore interface { // immutable transaction facts such as the serialized transaction bytes, // created credits, or spent-input edges. // - // UpdateTx is the only public tx-store API that may attach, replace, or - // clear confirming block metadata. + // UpdateTx is row-local only. It may attach, replace, or clear confirming + // block metadata for one tx, but it must not perform branch invalidation. + // Callers must use CreateTx, InvalidateUnminedTx, or RollbackToBlock for + // graph-affecting lifecycle changes. UpdateTx(ctx context.Context, params UpdateTxParams) error // GetTx retrieves a transaction record by its hash. It takes a context diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 1418f5ca3c..2a93607994 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -636,11 +636,14 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { ErrInvalidParam, state.Status, ErrInvalidStatus) } - // Only disconnected coinbase rows become orphaned. Ordinary - // transactions use the replaced/failed states instead, so UpdateTx - // must reject orphaned transitions for non-coinbase history. - if !isCoinbase && state.Status == TxStatusOrphaned { - return fmt.Errorf("%w: non-coinbase txns cannot be orphaned: %w", + // UpdateTx is row-local only. Any invalidating or orphaning transition must + // flow through the event-shaped APIs that also reconcile dependent branch + // state. + if state.Status == TxStatusFailed || + state.Status == TxStatusReplaced || + state.Status == TxStatusOrphaned { + + return fmt.Errorf("%w: UpdateTx cannot invalidate txns: %w", ErrInvalidParam, ErrInvalidStatus) } @@ -651,11 +654,11 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { ErrInvalidParam, ErrInvalidStatus) } - // A unmined coinbase row only appears after rollback disconnects its block, - // at which point the row must be marked orphaned rather than treated as an - // active unmined transaction. - if isCoinbase && state.Block == nil && state.Status != TxStatusOrphaned { - return fmt.Errorf("%w: unmined coinbase txns must be orphaned: %w", + // Coinbase state transitions are event-shaped only. CreateTx records the + // mined fact, while RollbackToBlock clears the block reference and rewrites + // the row orphaned. UpdateTx therefore never patches coinbase state. + if isCoinbase { + return fmt.Errorf("%w: UpdateTx cannot patch coinbase tx state: %w", ErrInvalidParam, ErrInvalidStatus) } diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index a6c2775e62..227be82067 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -916,6 +916,70 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { require.Equal(t, []string{"load"}, ops.calls) } +// TestUpdateTxWithOpsRejectsInvalidatingStates verifies that UpdateTx rejects +// branch-affecting state transitions before any backend write begins. +func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isCoinbase bool + state UpdateTxState + }{ + { + name: "failed rejected", + state: UpdateTxState{ + Status: TxStatusFailed, + }, + }, + { + name: "replaced rejected", + state: UpdateTxState{ + Status: TxStatusReplaced, + }, + }, + { + name: "orphaned rejected", + state: UpdateTxState{ + Status: TxStatusOrphaned, + }, + }, + { + name: "coinbase orphaned rejected", + isCoinbase: true, + state: UpdateTxState{ + Status: TxStatusOrphaned, + }, + }, + { + name: "coinbase confirmed patch rejected", + isCoinbase: true, + state: UpdateTxState{ + Status: TxStatusPublished, + Block: testBlock(55), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + State: &test.state, + } + ops := &stubUpdateTxOps{isCoinbase: test.isCoinbase} + + err := updateTxWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.ErrorIs(t, err, ErrInvalidStatus) + require.Equal(t, []string{"load"}, ops.calls) + }) + } +} + // stubUpdateTxOps records how the shared UpdateTx helper drives one backend // adapter while letting tests control the loaded metadata. type stubUpdateTxOps struct { From 86a0311f98601710afa189c3f9b2090007c0f201 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 02:52:44 +0800 Subject: [PATCH 207/695] wallet: use mock.Mock in utxo helper tests Replace the shared LeaseOutput and ReleaseOutput test stubs with mock-based adapters so the helper tests can assert the fallback paths through explicit expectations. This also drops redundant local call-tracking now that the mock expectations already prove the helper call order. --- wallet/internal/db/mock_test.go | 13 ++ wallet/internal/db/utxo_store_common_test.go | 215 +++++++++++-------- 2 files changed, 137 insertions(+), 91 deletions(-) create mode 100644 wallet/internal/db/mock_test.go diff --git a/wallet/internal/db/mock_test.go b/wallet/internal/db/mock_test.go new file mode 100644 index 0000000000..9370a9a464 --- /dev/null +++ b/wallet/internal/db/mock_test.go @@ -0,0 +1,13 @@ +package db + +import ( + "errors" + "fmt" +) + +var errMockType = errors.New("mock arg type") + +// mockTypeError wraps the shared mock type sentinel with call-site context. +func mockTypeError(detail string) error { + return fmt.Errorf("%w: %s", errMockType, detail) +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 418e77be22..f0918bde07 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -196,9 +197,13 @@ func TestLeaseOutputWithOps(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireExpiration: time.Unix(333, 0).In(time.FixedZone("X", 3600)), - } + acquireExpiration := time.Unix(333, 0).In(time.FixedZone("X", 3600)) + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + acquireExpiration, nil).Once() // Act: Run the shared LeaseOutput flow. lease, err := leaseOutputWithOps(context.Background(), params, ops) @@ -208,7 +213,6 @@ func TestLeaseOutputWithOps(t *testing.T) { require.Equal(t, params.OutPoint, lease.OutPoint) require.Equal(t, LockID(params.ID), lease.LockID) require.Equal(t, time.UTC, lease.Expiration.Location()) - require.Equal(t, []string{"acquire"}, ops.calls) } // TestLeaseOutputWithOpsRejectsNonPositiveDuration verifies that the shared @@ -222,11 +226,13 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { ID: LockID{7}, Duration: 0, } - ops := &stubLeaseOutputOps{} + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - require.Empty(t, ops.calls) + ops.AssertNotCalled(t, "acquire", mock.Anything, mock.Anything, + mock.Anything, mock.Anything) } // TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper @@ -240,13 +246,17 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireErr: errLeaseOutputNoRow, - } + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, errLeaseOutputNoRow).Once() + + ops.On("hasUtxo", mock.Anything, params).Return(false, nil).Once() _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) - require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } // TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput @@ -260,14 +270,17 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireErr: errLeaseOutputNoRow, - hasUtxoResult: true, - } + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, errLeaseOutputNoRow).Once() + + ops.On("hasUtxo", mock.Anything, params).Return(true, nil).Once() _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputAlreadyLeased) - require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } // TestReleaseOutputWithOps verifies that the shared ReleaseOutput helper @@ -281,17 +294,19 @@ func TestReleaseOutputWithOps(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - releaseRows: 1, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(1), nil).Once() // Act: Run the shared ReleaseOutput flow. err := releaseOutputWithOps(context.Background(), params, ops) // Assert: The helper stops after the successful delete path. require.NoError(t, err) - require.Equal(t, []string{"lookup-utxo", "release"}, ops.calls) } // TestReleaseOutputWithOpsMissingUtxo verifies that the shared ReleaseOutput @@ -304,13 +319,14 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDErr: errReleaseOutputUtxoNotFound, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("lookupUtxoID", mock.Anything, params).Return( + int64(0), errReleaseOutputUtxoNotFound).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) - require.Equal(t, []string{"lookup-utxo"}, ops.calls) } // TestReleaseOutputWithOpsWrongLock verifies that the shared ReleaseOutput @@ -323,17 +339,20 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - activeLockIDResult: []byte{1, 2, 3}, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + releaseTimeMatcher := mock.AnythingOfType("time.Time") + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(0), nil).Once() + + ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return([]byte{1, 2, 3}, nil).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) - require.Equal(t, - []string{"lookup-utxo", "release", "active-lock"}, - ops.calls, - ) } // TestReleaseOutputWithOpsMissingActiveLease verifies that the shared @@ -347,94 +366,108 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - activeLockIDErr: errReleaseOutputNoActiveLease, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + releaseTimeMatcher := mock.AnythingOfType("time.Time") + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(0), nil).Once() + + ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return(nil, errReleaseOutputNoActiveLease).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.NoError(t, err) - require.Equal(t, - []string{"lookup-utxo", "release", "active-lock"}, - ops.calls, - ) } -// stubLeaseOutputOps records how the shared LeaseOutput helper drives one -// backend adapter while letting each test control the returned results. -type stubLeaseOutputOps struct { - acquireExpiration time.Time - acquireErr error - hasUtxoResult bool - - calls []string +// mockLeaseOutputOps is a mock implementation of leaseOutputOps. +type mockLeaseOutputOps struct { + mock.Mock } -var _ leaseOutputOps = (*stubLeaseOutputOps)(nil) +var _ leaseOutputOps = (*mockLeaseOutputOps)(nil) + +// acquire implements leaseOutputOps. +func (m *mockLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, start time.Time, + expiration time.Time) (time.Time, error) { -// acquire records the shared write attempt and returns the test-controlled -// lease result. -func (s *stubLeaseOutputOps) acquire(_ context.Context, - _ LeaseOutputParams, _ time.Time, _ time.Time) (time.Time, error) { + args := m.Called(ctx, params, start, expiration) - s.calls = append(s.calls, "acquire") + leaseExpiration, ok := args.Get(0).(time.Time) + if !ok { + return time.Time{}, errMockType + } - return s.acquireExpiration, s.acquireErr + return leaseExpiration, args.Error(1) } -// hasUtxo records the fallback ownership lookup and returns the test-controlled -// result. -func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, - _ LeaseOutputParams) (bool, error) { +// hasUtxo implements leaseOutputOps. +func (m *mockLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { - s.calls = append(s.calls, "has-utxo") + args := m.Called(ctx, params) - return s.hasUtxoResult, nil + hasUtxo, ok := args.Get(0).(bool) + if !ok { + return false, errMockType + } + + return hasUtxo, args.Error(1) } -// stubReleaseOutputOps records how the shared ReleaseOutput helper drives one -// backend adapter while letting each test control the returned results. -type stubReleaseOutputOps struct { - lookupUtxoIDResult int64 - lookupUtxoIDErr error - releaseRows int64 - releaseErr error - activeLockIDResult []byte - activeLockIDErr error - - calls []string +// mockReleaseOutputOps is a mock implementation of releaseOutputOps. +type mockReleaseOutputOps struct { + mock.Mock } -var _ releaseOutputOps = (*stubReleaseOutputOps)(nil) +var _ releaseOutputOps = (*mockReleaseOutputOps)(nil) + +// lookupUtxoID implements releaseOutputOps. +func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { -// lookupUtxoID records the shared outpoint lookup and returns the test- -// controlled result. -func (s *stubReleaseOutputOps) lookupUtxoID(_ context.Context, - _ ReleaseOutputParams) (int64, error) { + args := m.Called(ctx, params) - s.calls = append(s.calls, "lookup-utxo") + utxoID, ok := args.Get(0).(int64) + if !ok { + return 0, errMockType + } - return s.lookupUtxoIDResult, s.lookupUtxoIDErr + return utxoID, args.Error(1) } -// release records the shared delete attempt and returns the test-controlled row -// count. -func (s *stubReleaseOutputOps) release(_ context.Context, _ uint32, - _ int64, _ [32]byte) (int64, error) { +// release implements releaseOutputOps. +func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + args := m.Called(ctx, walletID, utxoID, lockID) - s.calls = append(s.calls, "release") + releasedRows, ok := args.Get(0).(int64) + if !ok { + return 0, errMockType + } - return s.releaseRows, s.releaseErr + return releasedRows, args.Error(1) } -// activeLockID records the fallback active-lock lookup and returns the test- -// controlled result. -func (s *stubReleaseOutputOps) activeLockID(_ context.Context, _ uint32, - _ int64, _ time.Time) ([]byte, error) { +// activeLockID implements releaseOutputOps. +func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, + walletID uint32, utxoID int64, now time.Time) ([]byte, error) { + + args := m.Called(ctx, walletID, utxoID, now) + if args.Get(0) == nil { + return nil, args.Error(1) + } - s.calls = append(s.calls, "active-lock") + lockID, ok := args.Get(0).([]byte) + if !ok { + return nil, errMockType + } - return s.activeLockIDResult, s.activeLockIDErr + return lockID, args.Error(1) } // testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease From 7e109edc3f377a16b7e23f1eaaf94c605149abf0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 02:06:05 +0800 Subject: [PATCH 208/695] wallet: use mock.Mock in tx store helper tests Replace the shared CreateTx and UpdateTx helper adapters with mock-based test doubles so expectations live directly in the helper setup instead of custom call-recording structs. This keeps the shared tx-store helper tests aligned with the existing invalidation mock style before later coverage commits add more cases. --- wallet/internal/db/tx_store_common_test.go | 674 ++++++++++++--------- 1 file changed, 380 insertions(+), 294 deletions(-) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 227be82067..d350d6fe87 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -319,28 +320,55 @@ func TestNewCreateTxRequest(t *testing.T) { func TestCreateTxWithOpsInsert(t *testing.T) { t.Parallel() - // Arrange: Build one prepared CreateTx request and one stub adapter. + // Arrange: Build one prepared CreateTx request and one mock adapter. req := testCreateTxRequest(t) - ops := &stubCreateTxOps{insertTxID: 11} + + var ( + insertReq createTxRequest + creditsID int64 + inputsID int64 + ) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + + ops.On("insert", mock.Anything, req).Return(int64(11), nil).Run( + func(args mock.Arguments) { + reqArg, ok := args.Get(1).(createTxRequest) + require.True(t, ok) + insertReq = reqArg + }, + ).Once() + + ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Run( + func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + creditsID = txID + }, + ).Once() + + ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( + func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + inputsID = txID + }, + ).Once() // Act: Run createTxWithOps. err := createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - // Assert: The shared flow executes the expected write sequence. - require.Equal(t, - []string{ - "load-existing", - "prepare-block", - "insert", - "credits", - "inputs", - }, - ops.calls, - ) - require.Equal(t, int64(11), ops.creditsTxID) - require.Equal(t, int64(11), ops.inputsTxID) - require.Equal(t, req.txHash, ops.insertReq.txHash) + // Assert: The shared flow uses the inserted tx ID consistently. + require.Equal(t, int64(11), creditsID) + require.Equal(t, int64(11), inputsID) + require.Equal(t, req.txHash, insertReq.txHash) } // TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an @@ -349,11 +377,14 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { t.Parallel() req := testCreateTxRequest(t) - ops := &stubCreateTxOps{existing: &createTxExistingTarget{id: 4}} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + &createTxExistingTarget{id: 4}, nil).Once() err := createTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) - require.Equal(t, []string{"load-existing"}, ops.calls) } // TestCreateTxWithOpsConfirmExisting verifies that the shared CreateTx flow can @@ -372,15 +403,20 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{existing: &createTxExistingTarget{ + existing := createTxExistingTarget{ id: 7, status: TxStatusPending, hasBlock: false, - }} + } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() + + ops.On("confirmExisting", mock.Anything, req, existing).Return(nil).Once() err = createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } // TestCreateTxWithOpsReplaceConflicts verifies that the shared CreateTx flow @@ -399,27 +435,40 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - insertTxID: 11, - conflictIDs: []int64{5}, - conflictHashes: []chainhash.Hash{{9}}, - } + rootIDs := []int64{5} + rootHashes := []chainhash.Hash{{9}} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + + ops.On("insert", mock.Anything, req).Return(int64(11), nil).Once() + + ops.On("listConflictTxns", mock.Anything, req).Return( + rootIDs, rootHashes, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(5)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). + Once() + + ops.On("markTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). + Once() + + ops.On("insertReplacementEdges", mock.Anything, int64(5), []int64{5}, + int64(11)).Return(nil).Once() + + ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Once() + + ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() err = createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - require.Equal(t, []string{ - "load-existing", - "prepare-block", - "insert", - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "credits", - "inputs", - }, ops.calls) - require.Equal(t, int64(11), ops.replacedConflictTxID) } // testBlock builds a simple block fixture for CreateTx validation tests. @@ -452,183 +501,221 @@ func testCoinbaseMsgTx() *wire.MsgTx { return tx } -// stubCreateTxOps records how the shared CreateTx helper drives one backend -// adapter while letting each test control the returned transaction IDs. -type stubCreateTxOps struct { - existing *createTxExistingTarget - insertTxID int64 - conflictIDs []int64 - conflictHashes []chainhash.Hash - unminedTxns []unminedTxRecord - - listConflictsErr error - listUnminedErr error - clearSpentErr error - markFailedErr error - - calls []string - insertReq createTxRequest - creditsTxID int64 - inputsTxID int64 - clearedTxIDs []int64 - replacedTxIDs []int64 - failedTxIDs []int64 - replacementEdgeTxIDs []int64 - replacedConflictTxID int64 +// mockCreateTxOps is a mock implementation of createTxOps. +type mockCreateTxOps struct { + mock.Mock } -var _ createTxOps = (*stubCreateTxOps)(nil) +var _ createTxOps = (*mockCreateTxOps)(nil) + +// loadExisting implements createTxOps. +func (m *mockCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { -// loadExisting records that the shared flow checked whether the tx hash already -// exists and returns the test-controlled result. -func (s *stubCreateTxOps) loadExisting(_ context.Context, - _ createTxRequest) (*createTxExistingTarget, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } - s.calls = append(s.calls, "load-existing") + existing, ok := args.Get(0).(*createTxExistingTarget) + if !ok { + return nil, mockTypeError("loadExisting result") + } - return s.existing, nil + return existing, args.Error(1) } -// confirmExisting records that the shared flow promoted one existing row. -func (s *stubCreateTxOps) confirmExisting(_ context.Context, - _ createTxRequest, - _ createTxExistingTarget) error { +// confirmExisting implements createTxOps. +func (m *mockCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, existing createTxExistingTarget) error { - s.calls = append(s.calls, "confirm-existing") + args := m.Called(ctx, req, existing) - return nil + return args.Error(0) } -// prepareBlock records that the shared flow validated any optional block -// assignment before insert. -func (s *stubCreateTxOps) prepareBlock(_ context.Context, - _ createTxRequest) error { +// prepareBlock implements createTxOps. +func (m *mockCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { - s.calls = append(s.calls, "prepare-block") + args := m.Called(ctx, req) - return nil + return args.Error(0) } -// listConflictTxns records that the shared flow checked for direct wallet-owned -// conflicts after insert and before input claims. -func (s *stubCreateTxOps) listConflictTxns(_ context.Context, - _ createTxRequest) ([]int64, []chainhash.Hash, error) { +// listConflictTxns implements createTxOps. +func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { - s.calls = append(s.calls, "list-conflicts") + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, nil, args.Error(2) + } + + txIDs, ok := args.Get(0).([]int64) + if !ok { + return nil, nil, mockTypeError("listConflictTxns ids") + } - if s.listConflictsErr != nil { - return nil, nil, s.listConflictsErr + hashes, ok := args.Get(1).([]chainhash.Hash) + if !ok { + return nil, nil, mockTypeError("listConflictTxns hashes") } - return s.conflictIDs, s.conflictHashes, nil + return txIDs, hashes, args.Error(2) } -// loadInvalidateTarget satisfies the embedded invalidateUnminedTxOps interface -// for the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) loadInvalidateTarget(_ context.Context, _ uint32, - _ chainhash.Hash) (invalidateUnminedTxTarget, error) { +// loadInvalidateTarget implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, + txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { - return invalidateUnminedTxTarget{}, nil + var zeroTarget invalidateUnminedTxTarget + + args := m.Called(ctx, walletID, txHash) + if args.Get(0) == nil { + return zeroTarget, args.Error(1) + } + + target, ok := args.Get(0).(invalidateUnminedTxTarget) + if !ok { + return zeroTarget, mockTypeError("loadInvalidateTarget result") + } + + return target, args.Error(1) } -// listUnminedTxRecords satisfies the embedded invalidateUnminedTxOps interface -// for the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) listUnminedTxRecords(_ context.Context, - _ int64) ([]unminedTxRecord, error) { +// listUnminedTxRecords implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) { - s.calls = append(s.calls, "list-unmined-records") + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } - if s.listUnminedErr != nil { - return nil, s.listUnminedErr + records, ok := args.Get(0).([]unminedTxRecord) + if !ok { + return nil, mockTypeError("listUnminedTxRecords result") } - return s.unminedTxns, nil + return records, args.Error(1) } -// clearSpentUtxos satisfies the embedded invalidateUnminedTxOps interface for -// the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) clearSpentUtxos(_ context.Context, _ int64, +// clearSpentUtxos implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { - s.calls = append(s.calls, "clear-spent-utxos") - s.clearedTxIDs = append(s.clearedTxIDs, txID) + args := m.Called(ctx, walletID, txID) - if s.clearSpentErr != nil { - return s.clearSpentErr - } + return args.Error(0) +} + +// markTxnsFailed implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, + txIDs []int64) error { + + args := m.Called(ctx, walletID, txIDs) - return nil + return args.Error(0) } -// markTxnsFailed satisfies the embedded invalidateUnminedTxOps interface for -// the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) markTxnsFailed(_ context.Context, _ int64, +// markTxnsReplaced implements createTxOps. +func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error { - s.calls = append(s.calls, "mark-txns-failed") - s.failedTxIDs = append([]int64(nil), txIDs...) + args := m.Called(ctx, walletID, txIDs) + + return args.Error(0) +} + +// insertReplacementEdges implements createTxOps. +func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, + walletID int64, replacedTxIDs []int64, replacementTxID int64) error { - if s.markFailedErr != nil { - return s.markFailedErr + args := m.Called(ctx, walletID, replacedTxIDs, replacementTxID) + + return args.Error(0) +} + +// insert implements createTxOps. +func (m *mockCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + args := m.Called(ctx, req) + + txID, ok := args.Get(0).(int64) + if !ok { + return 0, mockTypeError("insert result") } - return nil + return txID, args.Error(1) } -// markTxnsReplaced satisfies the replacement hooks CreateTx uses for direct -// conflict reconciliation. -func (s *stubCreateTxOps) markTxnsReplaced(_ context.Context, _ int64, - txIDs []int64) error { +// insertCredits implements createTxOps. +func (m *mockCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { - s.calls = append(s.calls, "mark-txns-replaced") - s.replacedTxIDs = append([]int64(nil), txIDs...) + args := m.Called(ctx, req, txID) - return nil + return args.Error(0) } -// insertReplacementEdges satisfies the replacement hooks CreateTx uses for -// direct conflict reconciliation. -func (s *stubCreateTxOps) insertReplacementEdges(_ context.Context, _ int64, - replacedTxIDs []int64, replacementTxID int64) error { +// markInputsSpent implements createTxOps. +func (m *mockCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { - s.calls = append(s.calls, "insert-replacement-edges") - s.replacementEdgeTxIDs = append([]int64(nil), replacedTxIDs...) - s.replacedConflictTxID = replacementTxID + args := m.Called(ctx, req, txID) - return nil + return args.Error(0) } -// insert records the request that the shared flow would store as a fresh -// transaction row. -func (s *stubCreateTxOps) insert(_ context.Context, - req createTxRequest) (int64, error) { +// mockUpdateTxOps is a mock implementation of updateTxOps. +type mockUpdateTxOps struct { + mock.Mock +} + +var _ updateTxOps = (*mockUpdateTxOps)(nil) + +// loadIsCoinbase implements updateTxOps. +func (m *mockUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) { + + args := m.Called(ctx, walletID, txHash) + + isCoinbase, ok := args.Get(0).(bool) + if !ok { + return false, mockTypeError("loadIsCoinbase result") + } - s.calls = append(s.calls, "insert") - s.insertReq = req + return isCoinbase, args.Error(1) +} + +// prepareState implements updateTxOps. +func (m *mockUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { - return s.insertTxID, nil + args := m.Called(ctx, state) + + return args.Error(0) } -// insertCredits records the transaction ID the shared flow used when -// reconciling wallet-owned outputs. -func (s *stubCreateTxOps) insertCredits(_ context.Context, - _ createTxRequest, txID int64) error { +// updateLabel implements updateTxOps. +func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { - s.calls = append(s.calls, "credits") - s.creditsTxID = txID + args := m.Called(ctx, walletID, txHash, label) - return nil + return args.Error(0) } -// markInputsSpent records the transaction ID the shared flow used when -// attaching wallet-owned spent inputs. -func (s *stubCreateTxOps) markInputsSpent(_ context.Context, - _ createTxRequest, txID int64) error { +// updateState implements updateTxOps. +func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, state UpdateTxState) error { - s.calls = append(s.calls, "inputs") - s.inputsTxID = txID + args := m.Called(ctx, walletID, txHash, state) - return nil + return args.Error(0) } // testCreateTxRequest builds one valid normalized CreateTx request for the @@ -668,17 +755,20 @@ func TestCollectConflictDescendants(t *testing.T) { PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, }}}, }} - ops := &stubCreateTxOps{unminedTxns: candidates} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + descendantIDs, err := collectConflictDescendants( context.Background(), 7, rootHashes, rootIDs, ops, ) require.NoError(t, err) require.Equal(t, []int64{13}, descendantIDs) - require.Equal(t, []string{"list-unmined-records"}, ops.calls) } // TestHandleTxConflicts verifies the shared replacement flow for @@ -712,29 +802,36 @@ func TestHandleTxConflicts(t *testing.T) { }}}, }} - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{rootHash}, - unminedTxns: candidates, - } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{rootHash}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + Once() + + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). + Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) - require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) - require.Equal(t, []int64{1}, ops.replacedTxIDs) - require.Equal(t, []int64{2, 3}, ops.failedTxIDs) - require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) - require.Equal(t, int64(9), ops.replacedConflictTxID) } // TestHandleTxConflictsKeepsDirectRootsReplaced verifies that a @@ -755,10 +852,17 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1, 2}, - conflictHashes: []chainhash.Hash{rootAHash, rootBHash}, - unminedTxns: []unminedTxRecord{{ + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1, 2}, + []chainhash.Hash{rootAHash, rootBHash}, + nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord{{ id: 2, hash: rootBHash, tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ @@ -770,25 +874,23 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, }}}, - }}, - } + }}, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, + int64(9)).Return(nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + Once() + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). + Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) - require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) - require.Equal(t, []int64{1, 2}, ops.replacedTxIDs) - require.Equal(t, []int64{3}, ops.failedTxIDs) - require.Equal(t, []int64{1, 2}, ops.replacementEdgeTxIDs) } // TestHandleTxConflictsNoDescendants verifies that the helper @@ -805,25 +907,25 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{{1}}, - } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - }, ops.calls) - require.Equal(t, []int64{1}, ops.clearedTxIDs) - require.Equal(t, []int64{1}, ops.replacedTxIDs) - require.Empty(t, ops.failedTxIDs) - require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) - require.Equal(t, int64(9), ops.replacedConflictTxID) } // TestHandleTxConflictsMarkFailedError verifies that the helper records @@ -840,10 +942,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{{1}}, - unminedTxns: []unminedTxRecord{{ + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord{{ id: 2, hash: chainhash.Hash{2}, tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ @@ -852,22 +959,22 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { Index: 0, }, }}}, - }}, - markFailedErr: errConflictMarkFailed, - } + }}, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( + errConflictMarkFailed).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.ErrorIs(t, err, errConflictMarkFailed) require.ErrorContains(t, err, "mark conflict descendants failed") - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) } // TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow @@ -885,19 +992,45 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { Status: TxStatusPublished, }, } - ops := &stubUpdateTxOps{isCoinbase: false} + + var ( + updatedLabel string + updatedState UpdateTxState + ) + + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(false, nil).Once() + + ops.On("prepareState", mock.Anything, UpdateTxState{ + Status: TxStatusPublished, + }).Return(nil).Once() + + ops.On("updateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, + label).Return(nil).Run(func(args mock.Arguments) { + labelArg, ok := args.Get(3).(string) + require.True(t, ok) + updatedLabel = labelArg + }).Once() + + ops.On("updateState", mock.Anything, uint32(5), chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}).Return(nil).Run( + func(args mock.Arguments) { + stateArg, ok := args.Get(3).(UpdateTxState) + require.True(t, ok) + updatedState = stateArg + }, + ).Once() // Act: Run updateTxWithOps against a stub backend adapter. err := updateTxWithOps(context.Background(), params, ops) require.NoError(t, err) - // Assert: The shared flow loads, prepares, and applies both patches. - require.Equal(t, - []string{"load", "prepare-state", "label", "state"}, - ops.calls, - ) - require.Equal(t, label, ops.updatedLabel) - require.Equal(t, TxStatusPublished, ops.updatedState.Status) + // Assert: The shared flow applies both patches. + require.Equal(t, label, updatedLabel) + require.Equal(t, TxStatusPublished, updatedState.Status) } // TestUpdateTxWithOpsEmptyPatch verifies that the shared UpdateTx helper @@ -909,11 +1042,14 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { WalletID: 5, Txid: chainhash.Hash{1}, } - ops := &stubUpdateTxOps{isCoinbase: false} + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}).Return( + false, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - require.Equal(t, []string{"load"}, ops.calls) } // TestUpdateTxWithOpsRejectsInvalidatingStates verifies that UpdateTx rejects @@ -970,65 +1106,15 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { Txid: chainhash.Hash{1}, State: &test.state, } - ops := &stubUpdateTxOps{isCoinbase: test.isCoinbase} + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(test.isCoinbase, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) require.ErrorIs(t, err, ErrInvalidStatus) - require.Equal(t, []string{"load"}, ops.calls) }) } } - -// stubUpdateTxOps records how the shared UpdateTx helper drives one backend -// adapter while letting tests control the loaded metadata. -type stubUpdateTxOps struct { - isCoinbase bool - calls []string - updatedLabel string - updatedState UpdateTxState -} - -var _ updateTxOps = (*stubUpdateTxOps)(nil) - -// loadIsCoinbase records that the shared flow loaded the existing transaction -// row metadata. -func (s *stubUpdateTxOps) loadIsCoinbase(_ context.Context, _ uint32, - _ chainhash.Hash) (bool, error) { - - s.calls = append(s.calls, "load") - - return s.isCoinbase, nil -} - -// prepareState records that the shared flow validated and prepared one state -// patch before applying it. -func (s *stubUpdateTxOps) prepareState(_ context.Context, - _ UpdateTxState) error { - - s.calls = append(s.calls, "prepare-state") - - return nil -} - -// updateLabel records the label value the shared flow asked the backend to -// write. -func (s *stubUpdateTxOps) updateLabel(_ context.Context, _ uint32, - _ chainhash.Hash, label string) error { - - s.calls = append(s.calls, "label") - s.updatedLabel = label - - return nil -} - -// updateState records the state patch the shared flow asked the backend to -// write. -func (s *stubUpdateTxOps) updateState(_ context.Context, _ uint32, - _ chainhash.Hash, state UpdateTxState) error { - - s.calls = append(s.calls, "state") - s.updatedState = state - - return nil -} From 86986c726b19c65982f651262fbe1971b41c6b62 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:13:32 +0800 Subject: [PATCH 209/695] wallet: add tx status and utxo helper coverage --- wallet/internal/db/data_types_test.go | 33 +++++++ wallet/internal/db/utxo_store_common_test.go | 97 ++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 wallet/internal/db/data_types_test.go diff --git a/wallet/internal/db/data_types_test.go b/wallet/internal/db/data_types_test.go new file mode 100644 index 0000000000..122f80b249 --- /dev/null +++ b/wallet/internal/db/data_types_test.go @@ -0,0 +1,33 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTxStatusString verifies the public string form for every persisted tx +// status value. +func TestTxStatusString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status TxStatus + want string + }{ + {name: "pending", status: TxStatusPending, want: "pending"}, + {name: "published", status: TxStatusPublished, want: "published"}, + {name: "replaced", status: TxStatusReplaced, want: "replaced"}, + {name: "failed", status: TxStatusFailed, want: "failed"}, + {name: "orphaned", status: TxStatusOrphaned, want: "orphaned"}, + {name: "unknown", status: TxStatus(99), want: "unknown"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, test.status.String()) + }) + } +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index f0918bde07..4f6c7fd6d6 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -2,9 +2,11 @@ package db import ( "context" + "database/sql" "testing" "time" + "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/mock" @@ -475,3 +477,98 @@ func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, func testLeaseOutPoint() wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} } + +// TestBuildUtxoInfoMaxAmount verifies that buildUtxoInfo preserves the largest +// valid satoshi amount. +func TestBuildUtxoInfoMaxAmount(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{10} + info, err := buildUtxoInfo( + hash[:], 3, int64(btcutil.MaxSatoshi), []byte{0x53}, + time.Unix(333, 0), false, nil, + ) + + require.NoError(t, err) + require.Equal(t, btcutil.Amount(btcutil.MaxSatoshi), info.Amount) + require.Equal(t, UnminedHeight, info.Height) +} + +// TestBuildUtxoInfoInvalidHash verifies that buildUtxoInfo rejects malformed +// hash bytes. +func TestBuildUtxoInfoInvalidHash(t *testing.T) { + t.Parallel() + + _, err := buildUtxoInfo( + []byte{1, 2, 3}, 6, 1000, []byte{0x56}, time.Unix(666, 0), false, nil, + ) + + require.Error(t, err) +} + +// TestBuildLeasedOutputInvalidHash verifies that buildLeasedOutput rejects +// malformed hash bytes. +func TestBuildLeasedOutputInvalidHash(t *testing.T) { + t.Parallel() + + lockID := make([]byte, 32) + _, err := buildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) + + require.Error(t, err) +} + +// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies that the sqlite row +// decoder rejects output indexes outside the uint32 range. +func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{13} + _, err := utxoInfoFromSqliteRow( + hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, + sql.NullInt64{}, + ) + + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies that the sqlite row +// decoder rejects invalid confirmed block heights. +func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{14} + _, err := utxoInfoFromSqliteRow( + hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, + sql.NullInt64{Int64: -1, Valid: true}, + ) + + require.ErrorContains(t, err, "utxo block height") +} + +// TestUtxoInfoFromPgRowInvalidOutputIndex verifies that the postgres row +// decoder rejects output indexes outside the uint32 range. +func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{15} + _, err := utxoInfoFromPgRow( + hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, + sql.NullInt32{}, + ) + + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromPgRowInvalidBlockHeight verifies that the postgres row +// decoder rejects invalid confirmed block heights. +func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{16} + _, err := utxoInfoFromPgRow( + hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, + sql.NullInt32{Int32: -1, Valid: true}, + ) + + require.ErrorContains(t, err, "utxo block height") +} From 202ac9c692079589ae272ab653014fce4123e1cd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:14:21 +0800 Subject: [PATCH 210/695] wallet: add tx store unit coverage Add coverage for the shared tx-store helpers and backend error wrapping so the db package catches the current delete and rollback edge cases on top of the enhance branch wording. --- .../db/tx_store_backend_error_test.go | 114 ++++++++++++++++++ wallet/internal/db/tx_store_common_test.go | 35 ++++++ 2 files changed, 149 insertions(+) create mode 100644 wallet/internal/db/tx_store_backend_error_test.go diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go new file mode 100644 index 0000000000..1b5d0b6268 --- /dev/null +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -0,0 +1,114 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +var errDummy = errors.New("dummy") + +// errorDBTX forces sqlc exec/query calls down their wrapped error paths. +type errorDBTX struct { + execErr error + queryErr error +} + +// ExecContext implements the sqlc DBTX interface. +func (e errorDBTX) ExecContext(context.Context, string, + ...interface{}) (sql.Result, error) { + + return nil, e.execErr +} + +// PrepareContext implements the sqlc DBTX interface. +func (e errorDBTX) PrepareContext(context.Context, + string) (*sql.Stmt, error) { + + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryContext(context.Context, string, + ...interface{}) (*sql.Rows, error) { + + return nil, e.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryRowContext(context.Context, string, + ...interface{}) *sql.Row { + + return &sql.Row{} +} + +// TestPgDeleteAndRollbackOpsWrapBackendErrors verifies that the postgres delete +// and rollback adapters preserve their step-specific error context when sqlc +// exec and query calls fail. +func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcpg.New(errorDBTX{ + execErr: errDummy, + queryErr: errDummy, + }) + deleteOps := pgDeleteTxOps{qtx: qtx} + rollbackOps := pgRollbackToBlockOps{qtx: qtx} + + err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.deleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} + +// TestSqliteDeleteAndRollbackOpsWrapBackendErrors verifies that the sqlite +// delete and rollback adapters preserve their step-specific error context when +// sqlc exec and query calls fail. +func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + deleteOps := sqliteDeleteTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + + err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.deleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index d350d6fe87..818ae9bf55 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -80,6 +80,41 @@ func TestParseTxStatus(t *testing.T) { } } +// TestParseTxStatusNegativeValue verifies that parseTxStatus rejects negative +// stored values before they can map into the public TxStatus enum. +func TestParseTxStatusNegativeValue(t *testing.T) { + t.Parallel() + + _, err := parseTxStatus(-1) + require.ErrorIs(t, err, ErrInvalidStatus) +} + +// TestIsUnminedStatus verifies the delete-specific classification for each +// tx status. +func TestIsUnminedStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status TxStatus + want bool + }{ + {name: "pending", status: TxStatusPending, want: true}, + {name: "published", status: TxStatusPublished, want: true}, + {name: "replaced", status: TxStatusReplaced, want: false}, + {name: "failed", status: TxStatusFailed, want: false}, + {name: "orphaned", status: TxStatusOrphaned, want: false}, + {name: "unknown", status: TxStatus(99), want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, isUnminedStatus(test.status)) + }) + } +} + // TestBuildTxInfo verifies the shared row-to-domain conversion used by both // SQL backends when returning a valid TxInfo value. func TestBuildTxInfo(t *testing.T) { From 67527efc19b75c293607b930dc757b20b2b737bf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:08 +0800 Subject: [PATCH 211/695] wallet: add tx corruption itest fixtures --- .../db/itest/tx_corruption_pg_test.go | 161 ++++++++++++++ .../db/itest/tx_corruption_sqlite_test.go | 205 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 wallet/internal/db/itest/tx_corruption_pg_test.go create mode 100644 wallet/internal/db/itest/tx_corruption_sqlite_test.go diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go new file mode 100644 index 0000000000..a1c0745138 --- /dev/null +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -0,0 +1,161 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// These postgres-only helpers intentionally drop backend CHECK constraints in +// the isolated test database so corruption itests can persist rows that normal +// store writes would reject. The follow-up read paths must treat those rows as +// corruption instead of silently accepting them. + +// corruptTransactionStatus writes an invalid tx status after dropping the +// validating constraints that normally reject it. The corruption itests use +// this to verify that reads reject impossible tx states. +func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, status int64) { + t.Helper() + + for _, stmt := range []string{ + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS valid_status", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_orphaned_coinbase_only", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_confirmed_published", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_coinbase_not_pending", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_coinbase_confirmation_state", + } { + _, err := store.DB().ExecContext(t.Context(), stmt) + require.NoError(t, err) + } + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET tx_status = $1 WHERE wallet_id = $2 AND tx_hash = $3", + status, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptTransactionHash writes malformed tx-hash bytes after dropping the +// fixed-length hash check. The corruption itests then verify that hash +// decoding fails with the expected error path. +func corruptTransactionHash(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, hash []byte) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_tx_hash_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET tx_hash = $1 WHERE wallet_id = $2 AND tx_hash = $3", + hash, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptTransactionBlockHeight writes an invalid block height after dropping +// the non-negative height check and creating a matching block row. The +// corruption itests use this to verify that reads reject impossible +// confirmation metadata. +func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, height int64) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE blocks DROP CONSTRAINT IF EXISTS blocks_block_height_check", + ) + require.NoError(t, err) + + blockHash := RandomHash() + _, err = store.DB().ExecContext( + t.Context(), + "INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) "+ + "ON CONFLICT (block_height) DO UPDATE SET header_hash = EXCLUDED.header_hash, "+ + "block_timestamp = EXCLUDED.block_timestamp", + height, blockHash[:], time.Now().Unix(), + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET block_height = $1 WHERE wallet_id = $2 AND tx_hash = $3", + height, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptUtxoOutputIndex writes an invalid output index after dropping the +// non-negative output-index check. The corruption itests then verify that UTXO +// decoding rejects the malformed persisted value. +func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE utxos DROP CONSTRAINT IF EXISTS utxos_output_index_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE utxos SET output_index = $1 WHERE output_index = $2 "+ + "AND tx_id = (SELECT id FROM transactions WHERE wallet_id = $3 AND tx_hash = $4)", + newIndex, int64(oldIndex), int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the +// fixed-length lock-id check. The corruption itests use this to verify that +// lease reads reject malformed lock identifiers. +func corruptActiveLeaseLockID(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE utxo_leases DROP CONSTRAINT IF EXISTS utxo_leases_lock_id_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE utxo_leases SET lock_id = $1 WHERE wallet_id = $2 AND utxo_id = ("+ + "SELECT u.id FROM utxos u JOIN transactions t ON t.id = u.tx_id "+ + "WHERE t.wallet_id = $3 AND t.tx_hash = $4 AND u.output_index = $5)", + lockID, int64(walletID), int64(walletID), txHash[:], int64(outputIndex), + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go new file mode 100644 index 0000000000..6197534d12 --- /dev/null +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -0,0 +1,205 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// These sqlite-only helpers intentionally bypass CHECK constraints in the +// isolated test database so corruption itests can persist rows that normal +// store writes would reject. The follow-up read paths must treat those rows as +// corruption instead of silently accepting them. + +// corruptTransactionStatus writes an invalid tx status into one stored row while +// sqlite check constraints are disabled inside the surrounding transaction. The +// corruption itests use this to verify that reads reject impossible tx states. +func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, status int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET tx_status = ? WHERE wallet_id = ? AND tx_hash = ?", + status, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptTransactionHash writes malformed tx-hash bytes into one stored row +// while sqlite check constraints are disabled. The corruption itests then +// verify that hash decoding fails with the expected error path. +func corruptTransactionHash(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, hash []byte) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET tx_hash = ? WHERE wallet_id = ? AND tx_hash = ?", + hash, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptTransactionBlockHeight writes an invalid block height after first +// creating a matching block row in sqlite. The corruption itests use this to +// verify that reads reject impossible confirmation metadata. +func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, height int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + blockHash := RandomHash() + _, err = tx.ExecContext( + t.Context(), + "INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) "+ + "ON CONFLICT(block_height) DO UPDATE SET header_hash = excluded.header_hash, "+ + "block_timestamp = excluded.block_timestamp", + height, blockHash[:], time.Now().Unix(), + ) + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET block_height = ? WHERE wallet_id = ? AND tx_hash = ?", + height, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptUtxoOutputIndex writes an invalid output index into one stored UTXO +// while sqlite check constraints are disabled. The corruption itests then +// verify that UTXO decoding rejects the malformed persisted value. +func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE utxos SET output_index = ? WHERE output_index = ? "+ + "AND tx_id = (SELECT id FROM transactions WHERE wallet_id = ? AND tx_hash = ?)", + newIndex, int64(oldIndex), int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptActiveLeaseLockID writes an invalid lease lock ID into one active +// lease row while sqlite check constraints are disabled. The corruption itests +// use this to verify that lease reads reject malformed lock identifiers. +func corruptActiveLeaseLockID(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE utxo_leases SET lock_id = ? WHERE wallet_id = ? AND utxo_id = ("+ + "SELECT u.id FROM utxos u JOIN transactions t ON t.id = u.tx_id "+ + "WHERE t.wallet_id = ? AND t.tx_hash = ? AND u.output_index = ?)", + lockID, int64(walletID), int64(walletID), txHash[:], int64(outputIndex), + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} From 0510a928a002fd1971fa0907feb82648543d8b59 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:08 +0800 Subject: [PATCH 212/695] wallet: add create tx edge-case itests --- .../itest/tx_store_create_edge_cases_test.go | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_create_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go new file mode 100644 index 0000000000..69fc9d8a3c --- /dev/null +++ b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go @@ -0,0 +1,102 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxRejectsUnknownCreditAddress verifies that credited outputs must +// resolve to a wallet-owned address before CreateTx can store the UTXO row. +func TestCreateTxRejectsUnknownCreditAddress(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-unknown-credit-address") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2500, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000800, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.ErrorIs(t, err, db.ErrAddressNotFound) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestCreateTxRejectsInvalidParams verifies that CreateTx returns shared +// parameter-validation errors before opening a backend transaction. +func TestCreateTxRejectsInvalidParams(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalid-create-tx-params") + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Status: db.TxStatusPending, + }, + ) + require.ErrorContains(t, err, "tx is required") +} + +// TestCreateTxRejectsDuplicateConfirmedTransaction verifies that duplicate +// confirmed inserts fail instead of silently creating a second row. +func TestCreateTxRejectsDuplicateConfirmedTransaction(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-confirmed-tx") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 261) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 4500, PkScript: addr.ScriptPubKey}}, + ) + params := db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000850, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + } + + err := store.CreateTx(t.Context(), params) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), params) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) +} From 40e6970ba1394c175b7d80fb69f7847d5ba8b753 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 213/695] wallet: add tx store corruption itests --- .../db/itest/tx_store_corruption_test.go | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_corruption_test.go diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go new file mode 100644 index 0000000000..b4184baa1f --- /dev/null +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -0,0 +1,298 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestGetAndListTxRejectCorruptedStatus verifies that tx reads fail loudly when +// the stored status escapes the supported enum. +func TestGetAndListTxRejectCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-tx-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 265) + + pendingTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2100, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: pendingTx, + Received: time.Unix(1710000895, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 3100, PkScript: addr.ScriptPubKey}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000896, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, pendingTx.TxHash(), 99) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: pendingTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "invalid tx status") + + corruptTransactionStatus(t, store, walletID, confirmedTx.TxHash(), 99) + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: confirmedBlock.Height, + EndHeight: confirmedBlock.Height, + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestDeleteTxRejectsCorruptedStatus verifies that DeleteTx rejects stored rows +// with an invalid wallet-visible status code. +func TestDeleteTxRejectsCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-corrupted-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2300, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000897, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestListTxnsRejectsCorruptedUnminedHash verifies that unmined transaction +// listings fail when a stored transaction hash cannot be decoded. +func TestListTxnsRejectsCorruptedUnminedHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-unmined-hash") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2700, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001400, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, tx.TxHash(), []byte{1, 2, 3}) + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "tx hash") +} + +// TestGetTxRejectsCorruptedConfirmedBlockHeight verifies that confirmed reads +// fail when the joined block height cannot map back into the public block model. +func TestGetTxRejectsCorruptedConfirmedBlockHeight(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-confirmed-height") + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 280) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2800, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001410, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.NoError(t, err) + + corruptTransactionBlockHeight(t, store, walletID, tx.TxHash(), -1) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "block height") +} + +// TestDeleteTxRejectsCorruptedLiveChild verifies that DeleteTx surfaces child +// decode failures while checking the live leaf invariant. +func TestDeleteTxRejectsCorruptedLiveChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-corrupted-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001015, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001020, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, childTx.TxHash(), []byte{1, 2, 3}) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "tx hash") +} + +// TestRollbackToBlockRejectsCorruptedCoinbaseRootHash verifies that rollback +// fails loudly when a disconnected coinbase root carries an invalid stored hash. +func TestRollbackToBlockRejectsCorruptedCoinbaseRootHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rollback-corrupted-coinbase-hash") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + coinbaseBlock := CreateBlockFixture(t, queries, 290) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001420, 0), + Block: &coinbaseBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, coinbaseTx.TxHash(), []byte{1, 2, 3}) + + err = store.RollbackToBlock(t.Context(), coinbaseBlock.Height) + require.ErrorContains(t, err, "rollback coinbase hash") +} From 2d60801ea80be06783f5902097847042d3664006 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 214/695] wallet: add tx store edge-case itests --- .../db/itest/tx_store_edge_cases_test.go | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_edge_cases_test.go b/wallet/internal/db/itest/tx_store_edge_cases_test.go new file mode 100644 index 0000000000..66b025bb60 --- /dev/null +++ b/wallet/internal/db/itest/tx_store_edge_cases_test.go @@ -0,0 +1,202 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestDeleteTxRejectsConfirmedAndMissing verifies DeleteTx's live-unconfirmed +// precondition and not-found handling. +func TestDeleteTxRejectsConfirmedAndMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-confirmed-or-missing") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 260) + + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000900, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "delete requires an unmined transaction") + + confirmedInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.NoError(t, err) + require.NotNil(t, confirmedInfo.Block) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestDeleteTxRejectsNonLeafExternalChild verifies that DeleteTx scans the raw +// unmined graph, not only wallet-owned credit edges, when enforcing leaf-only +// deletion. +func TestDeleteTxRejectsNonLeafExternalChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-non-leaf-external-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{ + {Value: 6000, PkScript: addr.ScriptPubKey}, + {Value: 500, PkScript: []byte{0x51}}, + }, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 1}}, + []*wire.TxOut{{Value: 300, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrDeleteRequiresLeaf) + + parentInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, parentInfo.Status) + + childInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, childInfo.Status) +} + +// TestTxReadsReturnQueryErrorsWhenClosed verifies that transaction read and +// update methods wrap backend query errors when the store is closed. +func TestTxReadsReturnQueryErrorsWhenClosed(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-closed-tx-reads") + err := store.Close() + require.NoError(t, err) + + label := "closed" + err = store.UpdateTx( + t.Context(), + db.UpdateTxParams{ + WalletID: walletID, + Txid: RandomHash(), + Label: &label, + }, + ) + require.ErrorContains(t, err, "begin tx") + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorContains(t, err, "get tx") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "list txns without block") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 1, + EndHeight: 1, + }, + ) + require.ErrorContains(t, err, "list txns by height") +} From 1de9c2e780b1b744f642dfa1795e020d27b51205 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 215/695] wallet: add utxo store edge-case itests --- .../db/itest/utxo_store_edge_cases_test.go | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 wallet/internal/db/itest/utxo_store_edge_cases_test.go diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go new file mode 100644 index 0000000000..76d28df269 --- /dev/null +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -0,0 +1,305 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestLeaseOutputMissingUtxo verifies that leasing a missing outpoint returns +// the public not-found error. +func TestLeaseOutputMissingUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-missing-utxo") + + _, err := store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + +// TestReleaseOutputMissingUtxo verifies that releasing a missing outpoint +// returns the public not-found error. +func TestReleaseOutputMissingUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-missing-utxo") + + err := store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + }, + ) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + +// TestReleaseOutputTwiceIsNoOp verifies that a second release becomes a no-op +// after the original lease has already been removed. +func TestReleaseOutputTwiceIsNoOp(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-output-twice") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 270) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001300, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseOutPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + Duration: time.Hour, + }, + ) + require.NoError(t, err) + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + }, + ) + require.NoError(t, err) + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + }, + ) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + require.NoError(t, err) + require.Empty(t, leases) +} + +// TestListLeasedOutputsRejectsCorruptedLockID verifies that active lease reads +// fail loudly when the stored lock ID cannot be decoded. +func TestListLeasedOutputsRejectsCorruptedLockID(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-lease-lock-id") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 271) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8100, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001430, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.NoError(t, err) + + corruptActiveLeaseLockID(t, store, walletID, tx.TxHash(), 0, []byte{1, 2, 3}) + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "lock id") +} + +// TestListLeasedOutputsRejectsCorruptedOutputIndex verifies that active lease +// reads fail when the joined UTXO output index falls outside the public range. +func TestListLeasedOutputsRejectsCorruptedOutputIndex(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-lease-output-index") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 272) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8200, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001440, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.NoError(t, err) + + corruptUtxoOutputIndex(t, store, walletID, tx.TxHash(), 0, -1) + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "lease output index") +} + +// TestGetUtxoAndLeaseRejectLargeOutputIndex verifies backend-specific handling +// for outpoint indexes that exceed the supported SQL integer range. +func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-large-output-index") + outPoint := wire.OutPoint{Hash: RandomHash(), Index: ^uint32(0)} + + _, err := store.GetUtxo( + t.Context(), + db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: outPoint, + }, + ) + + _, leaseErr := store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: outPoint, + Duration: time.Minute, + }, + ) + + releaseErr := store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: outPoint, + }, + ) + + if _, ok := any(store).(*db.PostgresStore); ok { + require.ErrorContains(t, err, "convert output index") + require.ErrorContains(t, leaseErr, "convert output index") + require.ErrorContains(t, releaseErr, "could not cast") + return + } + + require.ErrorIs(t, err, db.ErrUtxoNotFound) + require.ErrorIs(t, leaseErr, db.ErrUtxoNotFound) + require.ErrorIs(t, releaseErr, db.ErrUtxoNotFound) +} + +// TestUtxoReadsReturnQueryErrorsWhenClosed verifies that UTXO read methods wrap +// backend query errors when the underlying connection is closed. +func TestUtxoReadsReturnQueryErrorsWhenClosed(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-closed-utxo-reads") + err := store.Close() + require.NoError(t, err) + + _, err = store.GetUtxo( + t.Context(), + db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + }, + ) + require.ErrorContains(t, err, "get utxo") + + _, err = store.ListUTXOs( + t.Context(), + db.ListUtxosQuery{WalletID: walletID}, + ) + require.ErrorContains(t, err, "list utxos") + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "list active utxo leases") + + _, err = store.Balance( + t.Context(), + db.BalanceParams{WalletID: walletID}, + ) + require.ErrorContains(t, err, "balance") +} From 98194373c8be200b6b28da9df6c14e6dd2efae1c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 216/695] wallet: add postgres tx store overflow itests --- .../db/itest/tx_store_pg_only_test.go | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_pg_only_test.go diff --git a/wallet/internal/db/itest/tx_store_pg_only_test.go b/wallet/internal/db/itest/tx_store_pg_only_test.go new file mode 100644 index 0000000000..c1d193bbed --- /dev/null +++ b/wallet/internal/db/itest/tx_store_pg_only_test.go @@ -0,0 +1,81 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxRejectsBlockHeightOverflowPostgres verifies postgres-specific +// block-height conversion failures surface before insert. +func TestCreateTxRejectsBlockHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-overflow-height-pg") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 1000, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710005000, 0), + Block: &db.Block{ + Height: ^uint32(0), + Hash: RandomHash(), + Timestamp: time.Unix(1710005001, 0), + }, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "convert block height") +} + +// TestListTxnsRejectHeightOverflowPostgres verifies postgres-specific height +// conversion failures for confirmed transaction listings. +func TestListTxnsRejectHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-height-overflow-pg") + + _, err := store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: ^uint32(0), + EndHeight: 0, + }, + ) + require.ErrorContains(t, err, "convert start height") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 0, + EndHeight: ^uint32(0), + }, + ) + require.ErrorContains(t, err, "convert end height") +} + +// TestRollbackToBlockRejectsHeightOverflowPostgres verifies postgres-specific +// rollback height conversion failures. +func TestRollbackToBlockRejectsHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + err := store.RollbackToBlock(t.Context(), ^uint32(0)) + require.ErrorContains(t, err, "convert rollback height") +} From ed69db1ed0e74ef254d8ce46dc3dc8e036ea66d9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 21:33:24 +0800 Subject: [PATCH 217/695] wallet: add backend tx store error-path coverage Adapt the backend tx-store helper coverage to the current rollback and CreateTx helper names so the tests keep exercising the wrapped backend error paths on top of the current enhance branch. --- .../db/tx_store_backend_error_test.go | 219 ++++++++++++++++++ .../internal/db/tx_store_backend_rows_test.go | 215 +++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 wallet/internal/db/tx_store_backend_rows_test.go diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 1b5d0b6268..e9d67133bc 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -5,8 +5,10 @@ import ( "database/sql" "errors" "testing" + "time" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" @@ -112,3 +114,220 @@ func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } + +// TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper +// adapters preserve step-specific error context for create, invalidate, +// rollback, update, and release workflows. +func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{qtx: qtx}, + } + invalidateOps := pgInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := pgRollbackToBlockOps{qtx: qtx} + updateOps := &pgUpdateTxOps{qtx: qtx} + releaseOps := pgReleaseOutputOps{qtx: qtx} + + err := createOps.markTxnsReplaced( + t.Context(), 1, []int64{2}, + ) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.insertReplacementEdges( + t.Context(), 1, []int64{2}, 3, + ) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.markTxRootsOrphaned( + t.Context(), 1, []chainhash.Hash{{1}}, + ) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt32{} + updateOps.status = int16(TxStatusPublished) + err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} + +// TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store +// helper adapters preserve step-specific error context for create, invalidate, +// rollback, update, and release workflows. +func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: qtx, + }, + } + invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + updateOps := &sqliteUpdateTxOps{qtx: qtx} + releaseOps := sqliteReleaseOutputOps{qtx: qtx} + + err := createOps.markTxnsReplaced( + t.Context(), 1, []int64{2}, + ) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.insertReplacementEdges( + t.Context(), 1, []int64{2}, 3, + ) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.markTxRootsOrphaned( + t.Context(), 1, []chainhash.Hash{{1}}, + ) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt64{} + updateOps.status = int64(TxStatusPublished) + err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} + +// TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper +// branches that fail before issuing any SQL query. +func TestPgBackendHelpersRejectOverflow(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 1, + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: ^uint32(0), + }, + }}, + TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, + }, + Received: time.Unix(1, 0), + Status: TxStatusPending, + }) + require.NoError(t, err) + + _, err = collectPgConflictRootIDs( + t.Context(), nil, req, + ) + require.ErrorContains(t, err, "convert input outpoint index 0") + + _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) + require.ErrorContains(t, err, "convert credit index") + + err = markInputsSpentPg(t.Context(), nil, CreateTxParams{ + WalletID: 1, + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: ^uint32(0), + }, + }}, + }, + Status: TxStatusPending, + }, 3) + require.ErrorContains(t, err, "convert input outpoint index 0") + + err = pgRollbackToBlockOps{}.rewindWalletSyncStateHeights( + t.Context(), ^uint32(0), + ) + require.ErrorContains(t, err, "convert rollback height") + + err = pgRollbackToBlockOps{}.deleteBlocksAtOrAboveHeight( + t.Context(), ^uint32(0), + ) + require.ErrorContains(t, err, "convert rollback height") + + _, _, err = buildPgConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + ID: 1, + TxHash: []byte{1}, + TxStatus: 0, + }}, map[int64]struct{}{1: {}}) + require.ErrorContains(t, err, "tx hash") + + _, err = (&pgLeaseOutputOps{}).acquire(t.Context(), LeaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, + ID: [32]byte{1}, + }, time.Now(), time.Now().Add(time.Minute)) + require.ErrorContains(t, err, "convert output index") + + _, err = (&pgLeaseOutputOps{}).hasUtxo(t.Context(), LeaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, + ID: [32]byte{1}, + }) + require.ErrorContains(t, err, "convert output index") +} diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go new file mode 100644 index 0000000000..d271280a93 --- /dev/null +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -0,0 +1,215 @@ +package db + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// staticResult is a minimal sql.Result stub with a caller-controlled row count. +type staticResult struct { + rows int64 +} + +// LastInsertId implements sql.Result. +func (r staticResult) LastInsertId() (int64, error) { + return 0, nil +} + +// RowsAffected implements sql.Result. +func (r staticResult) RowsAffected() (int64, error) { + return r.rows, nil +} + +// rowDBTX is a sqlc DBTX stub that lets tests mix fixed exec counts with +// query-row scan failures from a temporary sqlite handle. +type rowDBTX struct { + row *sql.Row + queryErr error + execErr error + rows int64 +} + +// ExecContext implements the sqlc DBTX interface. +func (r rowDBTX) ExecContext(context.Context, string, + ...interface{}) (sql.Result, error) { + + if r.execErr != nil { + return nil, r.execErr + } + + return staticResult{rows: r.rows}, nil +} + +// PrepareContext implements the sqlc DBTX interface. +func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryContext(context.Context, string, + ...interface{}) (*sql.Rows, error) { + + return nil, r.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryRowContext(context.Context, string, + ...interface{}) *sql.Row { + + if r.row != nil { + return r.row + } + + return &sql.Row{} +} + +// newSQLiteRow creates a query row backed by an in-memory sqlite database so +// sqlc scan paths can fail without standing up a real store. +func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { + t.Helper() + + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + return db.QueryRowContext(t.Context(), query, args...) +} + +// TestPgCreateTxOpsAdditionalBranches covers remaining postgres CreateTx helper +// branches that are hard to reach through public integration tests alone. +func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + + _, err := (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).loadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := &Block{ + Hash: chainhash.Hash{3}, + Height: 7, + Timestamp: time.Unix(77, 0), + } + err = (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow( + t, "SELECT ?, ?, ?", + int64(block.Height), block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + }).confirmExisting(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + txHash: chainhash.Hash{9}, + }, createTxExistingTarget{}) + require.ErrorIs(t, err, ErrTxNotFound) + + _, _, err = (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + }).listConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestSqliteCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + + _, err := (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).loadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := &Block{ + Hash: chainhash.Hash{4}, + Height: 8, + Timestamp: time.Unix(88, 0), + } + err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow( + t, "SELECT ?, ?, ?", + int64(block.Height), block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + }).confirmExisting(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + txHash: chainhash.Hash{9}, + }, createTxExistingTarget{}) + require.ErrorIs(t, err, ErrTxNotFound) + + err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).prepareBlock(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + }) + require.ErrorContains(t, err, "get block by height") + + _, _, err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + }).listConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite +// release-helper query-row error wrappers. +func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + + _, err := ops.lookupUtxoID(context.Background(), ReleaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }) + require.ErrorContains(t, err, "lookup utxo row") + + _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) + require.ErrorContains(t, err, "lookup active lease row") +} From ecaa07fe05e219007f058eed48cdbd5c6e72f481 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:16:02 +0800 Subject: [PATCH 218/695] wallet: add shared tx store helper coverage Add additive coverage for the remaining shared CreateTx and UpdateTx helper branches on top of the mock-based tx-store helper adapters. This keeps the helper-only edge cases in one tests-only commit while adapting the old coverage to the current enhance helper names and error wording. --- wallet/internal/db/tx_store_common_test.go | 306 +++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 818ae9bf55..08a35d9bb4 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -1153,3 +1153,309 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { }) } } + +var errCreateTxTest = errors.New("create tx test") + +// TestCheckReuseCreateTx verifies the shared reuse decision for existing rows. +func TestCheckReuseCreateTx(t *testing.T) { + t.Parallel() + + coinbaseReq, err := newCreateTxRequest(CreateTxParams{ + WalletID: 9, + Tx: testCoinbaseMsgTx(), + Received: time.Unix(555, 0), + Block: testBlock(22), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + confirmedReq, err := newCreateTxRequest(CreateTxParams{ + WalletID: 9, + Tx: testRegularMsgTx(), + Received: time.Unix(556, 0), + Block: testBlock(23), + Status: TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + tests := []struct { + name string + req createTxRequest + existing createTxExistingTarget + want bool + }{ + { + name: "confirmed unmined row reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusPending, + }, + want: true, + }, + { + name: "missing block not reused", + req: testCreateTxRequest(t), + existing: createTxExistingTarget{ + status: TxStatusPending, + }, + }, + { + name: "existing confirmed row not reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusPublished, + hasBlock: true, + }, + }, + { + name: "non coinbase orphan not reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + }, + }, + { + name: "orphaned coinbase reused", + req: coinbaseReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + isCoinbase: true, + }, + want: true, + }, + { + name: "coinbase row not reused for non coinbase tx", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + isCoinbase: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, + checkReuseCreateTx(test.req, test.existing)) + }) + } +} + +// TestLoadCreateTxExisting verifies not-found and wrapped-error handling for +// the shared existing-row lookup. +func TestLoadCreateTxExisting(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + existing, found, err := loadCreateTxExisting(context.Background(), req, ops) + require.NoError(t, err) + require.False(t, found) + require.Nil(t, existing) + + ops.On("loadExisting", mock.Anything, req).Return(nil, nil).Once() + + existing, found, err = loadCreateTxExisting(context.Background(), req, ops) + require.NoError(t, err) + require.False(t, found) + require.Nil(t, existing) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxTest, + ).Once() + + _, _, err = loadCreateTxExisting(context.Background(), req, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "load create tx target") +} + +// TestHandleRootTxnsClearError verifies that root spend-clearing failures are +// returned before the helper mutates any later replacement state. +func TestHandleRootTxnsClearError(t *testing.T) { + t.Parallel() + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("clearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( + errCreateTxTest).Once() + + err := handleRootTxns(context.Background(), 9, []int64{1}, 11, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "clear replaced root spent utxos") +} + +// TestHandleTxConflictsEdgeError verifies that replacement edge writes are +// wrapped after the branch state has been updated. +func TestHandleTxConflictsEdgeError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil, + ).Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + nil, + ).Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "record conflict replacement edges") +} + +// TestHandleTxConflictsListError verifies that the helper returns +// descendant-discovery load failures before mutating the branch. +func TestHandleTxConflictsListError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + nil, errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "list create tx conflict candidates") +} + +// TestHandleTxConflictsMarkReplacedError verifies that the helper wraps +// direct-root replacement failures. +func TestHandleTxConflictsMarkReplacedError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil, + ).Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "mark direct conflicts replaced") +} + +// TestValidateUpdateTxState verifies the remaining shared UpdateTx state +// invariants not covered by the integration tests. +func TestValidateUpdateTxState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state UpdateTxState + isCoinbase bool + wantErr error + }{ + { + name: "invalid status", + state: UpdateTxState{Status: TxStatus(99)}, + wantErr: ErrInvalidParam, + }, + { + name: "non coinbase cannot orphan", + state: UpdateTxState{Status: TxStatusOrphaned}, + wantErr: ErrInvalidParam, + }, + { + name: "confirmed must be published", + state: UpdateTxState{ + Status: TxStatusFailed, + Block: testBlock(44), + }, + wantErr: ErrInvalidParam, + }, + { + name: "coinbase patch rejected", + state: UpdateTxState{Status: TxStatusPublished}, + isCoinbase: true, + wantErr: ErrInvalidParam, + }, + { + name: "published confirmed valid", + state: UpdateTxState{ + Status: TxStatusPublished, + Block: testBlock(45), + }, + wantErr: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateUpdateTxState(test.state, test.isCoinbase) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + return + } + + require.NoError(t, err) + }) + } +} From 3bcf2fdec9491878df26e09b5d8ee985da5cfcc2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 22:43:21 +0800 Subject: [PATCH 219/695] wallet: add tx invalidation and corruption itests Add additive integration coverage for unmined invalidation flows and corruption-driven backend failures in the tx store. These cases verify that the public APIs surface the same invalidation, rollback, and query-error behavior the backend wrappers cover in unit tests on top of the current invalidation sentinel names. --- .../db/itest/tx_store_corruption_test.go | 327 ++++++++++++++++++ .../tx_store_invalidate_edge_cases_test.go | 164 +++++++++ 2 files changed, 491 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index b4184baa1f..f4958c89b9 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -3,6 +3,8 @@ package itest import ( + "database/sql" + "fmt" "testing" "time" @@ -12,6 +14,21 @@ import ( "github.com/stretchr/testify/require" ) +// dropTableForCorruption removes one backing table so the public store methods +// surface the backend query errors exercised by the corruption tests. +func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, + table string) { + t.Helper() + + stmt := fmt.Sprintf("DROP TABLE %s", table) + if _, ok := any(store).(*db.PostgresStore); ok { + stmt += " CASCADE" + } + + _, err := store.DB().ExecContext(t.Context(), stmt) + require.NoError(t, err) +} + // TestGetAndListTxRejectCorruptedStatus verifies that tx reads fail loudly when // the stored status escapes the supported enum. func TestGetAndListTxRejectCorruptedStatus(t *testing.T) { @@ -133,6 +150,84 @@ func TestDeleteTxRejectsCorruptedStatus(t *testing.T) { require.ErrorContains(t, err, "invalid tx status") } +// TestCreateTxRejectsCorruptedExistingStatus verifies that CreateTx surfaces an +// invalid stored status while checking whether the tx hash already exists. +func TestCreateTxRejectsCorruptedExistingStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-corrupted-existing-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2350, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000898, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000899, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestInvalidateUnminedTxRejectsCorruptedStatus verifies that invalidation +// rejects a stored root whose wallet-visible status is corrupted. +func TestInvalidateUnminedTxRejectsCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-corrupted-status") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2400, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000901, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + // TestListTxnsRejectsCorruptedUnminedHash verifies that unmined transaction // listings fail when a stored transaction hash cannot be decoded. func TestListTxnsRejectsCorruptedUnminedHash(t *testing.T) { @@ -296,3 +391,235 @@ func TestRollbackToBlockRejectsCorruptedCoinbaseRootHash(t *testing.T) { err = store.RollbackToBlock(t.Context(), coinbaseBlock.Height) require.ErrorContains(t, err, "rollback coinbase hash") } + +// TestCreateTxReturnsQueryErrorWhenTransactionsTableMissing verifies that the +// backend loadExisting path surfaces query errors from the transactions table. +func TestCreateTxReturnsQueryErrorWhenTransactionsTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-missing-transactions-table") + + dropTableForCorruption(t, store, "transactions") + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: newRegularTx([]wire.OutPoint{randomOutPoint()}, []*wire.TxOut{{Value: 5500, PkScript: []byte{0x51}}}), + Received: time.Unix(1710001433, 0), + Status: db.TxStatusPending, + }, + ) + require.ErrorContains(t, err, "get tx metadata") +} + +// TestCreateTxReturnsQueryErrorWhenUtxosTableMissing verifies that conflict +// discovery surfaces backend query errors from the UTXO table. +func TestCreateTxReturnsQueryErrorWhenUtxosTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-missing-utxos-table") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5600, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001434, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + conflictRoot := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5500, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: conflictRoot, + Received: time.Unix(1710001435, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 292) + dropTableForCorruption(t, store, "utxos") + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5400, PkScript: []byte{0x52}}}, + ), + Received: time.Unix(1710001436, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "lookup input conflict") +} + +// TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing verifies that the +// backend rollback queries surface database errors when the block table is gone. +func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + dropTableForCorruption(t, store, "blocks") + + err := store.RollbackToBlock(t.Context(), 1) + require.Error(t, err) + + // SQLite still fails while rewinding wallet sync-state heights because + // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. + // PostgreSQL drops those dependent rows with CASCADE when the blocks table is + // removed, so rollback gets far enough to fail on the block delete instead. + _, ok := any(store).(*db.PostgresStore) + if ok { + require.ErrorContains(t, err, "delete blocks at or above height") + return + } + + require.ErrorContains(t, err, "rewind wallet sync state heights") +} + +// TestLeaseAndReleaseReturnQueryErrorsWhenLeaseTablesMissing verifies that the +// backend lease queries surface direct database errors when the lease table has +// been removed. +func TestLeaseAndReleaseReturnQueryErrorsWhenLeaseTablesMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-missing-utxo-lease-table") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + confirmedBlock := CreateBlockFixture(t, store.Queries(), 293) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5700, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001437, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + outPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + leaseID := RandomHash() + + dropTableForCorruption(t, store, "utxo_leases") + + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: outPoint, + Duration: time.Minute, + }, + ) + require.ErrorContains(t, err, "acquire lease row") + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: outPoint, + }, + ) + require.ErrorContains(t, err, "release lease row") +} + +// TestCreateTxRejectsCorruptedConflictRootHash verifies that confirmed conflict +// reconciliation fails loudly when one conflicting unmined root carries an +// invalid stored hash. +func TestCreateTxRejectsCorruptedConflictRootHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-corrupted-conflict-hash") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5400, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001430, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + conflictRoot := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5300, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: conflictRoot, + Received: time.Unix(1710001431, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash( + t, store, walletID, conflictRoot.TxHash(), []byte{1, 2, 3}, + ) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 291) + winnerTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5200, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: winnerTx, + Received: time.Unix(1710001432, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "tx hash") +} diff --git a/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go b/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go new file mode 100644 index 0000000000..c31aaf5e82 --- /dev/null +++ b/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go @@ -0,0 +1,164 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestInvalidateUnminedTxFailsBranch verifies that invalidating one unmined root +// fails the whole dependent branch and restores the wallet-owned parent output. +func TestInvalidateUnminedTxFailsBranch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-unmined-branch") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + rootTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: rootTx, + Received: time.Unix(1710003100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: rootTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4900, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710003110, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + grandchildTx := newRegularTx( + []wire.OutPoint{{Hash: childTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4800, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: grandchildTx, + Received: time.Unix(1710003120, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: rootTx.TxHash(), + }, + ) + require.NoError(t, err) + + rootInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: rootTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, rootInfo.Status) + require.Nil(t, rootInfo.Block) + + childInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, childInfo.Status) + + grandchildInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: grandchildTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + + require.Empty(t, childSpendingTxIDs(t, store, walletID, rootTx.TxHash())) +} + +// TestInvalidateUnminedTxRejectsConfirmedAndMissing verifies the backend load +// paths for confirmed rows and missing tx hashes. +func TestInvalidateUnminedTxRejectsConfirmedAndMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-unmined-errors") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + block := CreateBlockFixture(t, store.Queries(), 320) + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5200, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710003130, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrInvalidateTx) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} From 5589a391cd5b545c7fdf4c38e9a2a5e0f2aa8e8e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:16:47 +0800 Subject: [PATCH 220/695] wallet: add tx store edge-case itests Add additive integration coverage for the remaining CreateTx and UpdateTx edge conditions that were still below the db-only coverage target. These tests keep the branch focused on public store behavior while pushing the split backend files above the current 90 percent goal. --- .../itest/tx_store_create_edge_cases_test.go | 48 ++++++++ .../db/itest/tx_store_edge_cases_test.go | 36 ++++++ .../db/tx_store_backend_error_test.go | 6 +- .../internal/db/tx_store_backend_rows_test.go | 103 +++++++++++++++--- wallet/internal/db/tx_store_common_test.go | 61 ++++++++--- wallet/internal/db/utxo_store_common_test.go | 32 ++++-- 6 files changed, 241 insertions(+), 45 deletions(-) diff --git a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go index 69fc9d8a3c..24de82ae24 100644 --- a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go +++ b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go @@ -100,3 +100,51 @@ func TestCreateTxRejectsDuplicateConfirmedTransaction(t *testing.T) { err = store.CreateTx(t.Context(), params) require.ErrorIs(t, err, db.ErrTxAlreadyExists) } + +// TestCreateTxRejectsMissingConfirmingBlockForExistingUnminedRow verifies that +// re-confirming an existing unmined row still requires the confirming block to +// exist in block history. +func TestCreateTxRejectsMissingConfirmingBlockForExistingUnminedRow(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-missing-confirming-block") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 4600, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000860, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000861, 0), + Block: &db.Block{ + Hash: RandomHash(), + Height: 999, + Timestamp: time.Unix(1710000862, 0), + }, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }, + ) + require.ErrorContains(t, err, "require confirming block") +} diff --git a/wallet/internal/db/itest/tx_store_edge_cases_test.go b/wallet/internal/db/itest/tx_store_edge_cases_test.go index 66b025bb60..79d139514f 100644 --- a/wallet/internal/db/itest/tx_store_edge_cases_test.go +++ b/wallet/internal/db/itest/tx_store_edge_cases_test.go @@ -3,6 +3,7 @@ package itest import ( + "strings" "testing" "time" @@ -200,3 +201,38 @@ func TestTxReadsReturnQueryErrorsWhenClosed(t *testing.T) { ) require.ErrorContains(t, err, "list txns by height") } + +// TestUpdateTxRejectsTooLongLabel verifies that backend label updates surface +// query errors when the requested label exceeds the stored column limit. +func TestUpdateTxRejectsTooLongLabel(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-label-too-long") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 9100, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001600, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + label := strings.Repeat("x", 501) + err = store.UpdateTx( + t.Context(), + db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + Label: &label, + }, + ) + require.ErrorContains(t, err, "update tx label") +} diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index e9d67133bc..2918f47a62 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -317,14 +317,16 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }}, map[int64]struct{}{1: {}}) require.ErrorContains(t, err, "tx hash") - _, err = (&pgLeaseOutputOps{}).acquire(t.Context(), LeaseOutputParams{ + leaseOps := &pgLeaseOutputOps{} + + _, err = leaseOps.acquire(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = (&pgLeaseOutputOps{}).hasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.hasUtxo(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index d271280a93..357b4e7417 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -78,7 +78,9 @@ func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { db, err := sql.Open("sqlite", ":memory:") require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) + t.Cleanup(func() { + _ = db.Close() + }) return db.QueryRowContext(t.Context(), query, args...) } @@ -90,14 +92,15 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - - _, err := (&pgCreateTxOps{ + loadOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).loadExisting(ctx, req) + } + + _, err := loadOps.loadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -105,7 +108,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { Height: 7, Timestamp: time.Unix(77, 0), } - err = (&pgCreateTxOps{ + confirmOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow( @@ -116,20 +119,22 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { rows: 0, }), }, - }).confirmExisting(ctx, createTxRequest{ + } + err = confirmOps.confirmExisting(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, txHash: chainhash.Hash{9}, }, createTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) - _, _, err = (&pgCreateTxOps{ + conflictOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, - }).listConflictTxns(ctx, req) + } + _, _, err = conflictOps.listConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -141,14 +146,15 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - - _, err := (&sqliteCreateTxOps{ + loadOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).loadExisting(ctx, req) + } + + _, err := loadOps.loadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -156,7 +162,7 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { Height: 8, Timestamp: time.Unix(88, 0), } - err = (&sqliteCreateTxOps{ + confirmOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow( @@ -167,31 +173,34 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { rows: 0, }), }, - }).confirmExisting(ctx, createTxRequest{ + } + err = confirmOps.confirmExisting(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, txHash: chainhash.Hash{9}, }, createTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) - err = (&sqliteCreateTxOps{ + prepareOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).prepareBlock(ctx, createTxRequest{ + } + err = prepareOps.prepareBlock(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, }) require.ErrorContains(t, err, "get block by height") - _, _, err = (&sqliteCreateTxOps{ + conflictOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, - }).listConflictTxns(ctx, req) + } + _, _, err = conflictOps.listConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -213,3 +222,63 @@ func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) require.ErrorContains(t, err, "lookup active lease row") } + +// TestPgUpdateTxOpsAdditionalBranches covers the remaining postgres UpdateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + stateOps := &pgUpdateTxOps{ + qtx: sqlcpg.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt32{}, + status: int16(TxStatusPublished), + } + labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + + _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + Status: TxStatusPublished, + }) + require.ErrorIs(t, err, ErrTxNotFound) + + err = labelOps.updateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, ErrTxNotFound) +} + +// TestSqliteUpdateTxOpsAdditionalBranches covers the remaining sqlite UpdateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + stateOps := &sqliteUpdateTxOps{ + qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt64{}, + status: int64(TxStatusPublished), + } + labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + + _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + Status: TxStatusPublished, + }) + require.ErrorIs(t, err, ErrTxNotFound) + + err = labelOps.updateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, ErrTxNotFound) +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 08a35d9bb4..7e515c199c 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -365,7 +365,9 @@ func TestCreateTxWithOpsInsert(t *testing.T) { ) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( nil, errCreateTxExistingNotFound).Once() @@ -413,7 +415,9 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { req := testCreateTxRequest(t) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( &createTxExistingTarget{id: 4}, nil).Once() @@ -444,7 +448,9 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { hasBlock: false, } ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() @@ -473,7 +479,9 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { rootIDs := []int64{5} rootHashes := []chainhash.Hash{{9}} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( nil, errCreateTxExistingNotFound).Once() @@ -791,7 +799,9 @@ func TestCollectConflictDescendants(t *testing.T) { }}}, }} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} @@ -838,7 +848,9 @@ func TestHandleTxConflicts(t *testing.T) { }} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{rootHash}, nil, @@ -888,7 +900,9 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1, 2}, @@ -915,8 +929,8 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { Once() ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}).Return(nil). - Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). + Return(nil).Once() ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, int64(9)).Return(nil).Once() ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). @@ -943,7 +957,9 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, @@ -978,7 +994,9 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, @@ -1034,7 +1052,9 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { ) ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() @@ -1078,10 +1098,12 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { Txid: chainhash.Hash{1}, } ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}).Return( - false, nil).Once() + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(false, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) @@ -1142,10 +1164,13 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { State: &test.state, } ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). - Return(test.isCoinbase, nil).Once() + ops.On( + "loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, + ).Return(test.isCoinbase, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 4f6c7fd6d6..2582e2109f 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -201,7 +201,9 @@ func TestLeaseOutputWithOps(t *testing.T) { } acquireExpiration := time.Unix(333, 0).In(time.FixedZone("X", 3600)) ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -229,7 +231,9 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { Duration: 0, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) @@ -249,7 +253,9 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { Duration: time.Minute, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -273,7 +279,9 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { Duration: time.Minute, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -297,7 +305,9 @@ func TestReleaseOutputWithOps(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() @@ -322,7 +332,9 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("lookupUtxoID", mock.Anything, params).Return( int64(0), errReleaseOutputUtxoNotFound).Once() @@ -342,7 +354,9 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) releaseTimeMatcher := mock.AnythingOfType("time.Time") ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() @@ -369,7 +383,9 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) releaseTimeMatcher := mock.AnythingOfType("time.Time") ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() From 5134e49c3f566231c0c13862a39dd2ac9c349767 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 08:05:42 +0800 Subject: [PATCH 221/695] db: export shared backend helpers Export the shared helper types, functions, and adapter contracts that both SQL backends already use so handwritten backend files can move into backend-specific packages without introducing import cycles. This keeps the current behavior intact while preparing the later sqlite and pg file-split commits to stay mostly mechanical. This also refreshes the shared db tests that moved with the helper exports so the branch still matches the updated adr-0006-tests base. --- wallet/internal/db/accounts_common.go | 108 ++--- wallet/internal/db/accounts_pg.go | 44 +- wallet/internal/db/accounts_sqlite.go | 44 +- wallet/internal/db/address_types_common.go | 12 +- wallet/internal/db/address_types_pg.go | 6 +- wallet/internal/db/address_types_sqlite.go | 6 +- wallet/internal/db/addresses_common.go | 226 ++++----- wallet/internal/db/addresses_pg.go | 76 +-- wallet/internal/db/addresses_sqlite.go | 74 +-- wallet/internal/db/block_common.go | 4 +- wallet/internal/db/block_pg.go | 8 +- wallet/internal/db/block_sqlite.go | 4 +- .../internal/db/itest/account_store_test.go | 8 +- .../internal/db/itest/address_store_test.go | 24 +- wallet/internal/db/itest/fixtures_pg_test.go | 2 +- .../internal/db/itest/fixtures_sqlite_test.go | 2 +- wallet/internal/db/pg.go | 2 +- .../internal/db/postgres_txstore_createtx.go | 138 +++--- .../internal/db/postgres_txstore_deletetx.go | 34 +- wallet/internal/db/postgres_txstore_gettx.go | 2 +- .../db/postgres_txstore_invalidateunmined.go | 46 +- .../internal/db/postgres_txstore_listtxns.go | 6 +- .../internal/db/postgres_txstore_rollback.go | 46 +- .../internal/db/postgres_txstore_updatetx.go | 20 +- .../internal/db/postgres_utxostore_balance.go | 8 +- .../internal/db/postgres_utxostore_getutxo.go | 8 +- .../db/postgres_utxostore_leaseoutput.go | 22 +- .../postgres_utxostore_listleasedoutputs.go | 4 +- .../db/postgres_utxostore_listutxos.go | 6 +- .../db/postgres_utxostore_releaseoutput.go | 28 +- wallet/internal/db/safecasting.go | 42 +- wallet/internal/db/safecasting_test.go | 26 +- wallet/internal/db/sqlite.go | 2 +- wallet/internal/db/sqlite_txstore_createtx.go | 128 ++--- wallet/internal/db/sqlite_txstore_deletetx.go | 34 +- wallet/internal/db/sqlite_txstore_gettx.go | 2 +- .../db/sqlite_txstore_invalidateunmined.go | 46 +- wallet/internal/db/sqlite_txstore_listtxns.go | 2 +- wallet/internal/db/sqlite_txstore_rollback.go | 38 +- wallet/internal/db/sqlite_txstore_updatetx.go | 20 +- .../internal/db/sqlite_utxostore_balance.go | 8 +- .../internal/db/sqlite_utxostore_getutxo.go | 6 +- .../db/sqlite_utxostore_leaseoutput.go | 18 +- .../db/sqlite_utxostore_listleasedoutputs.go | 4 +- .../internal/db/sqlite_utxostore_listutxos.go | 6 +- .../db/sqlite_utxostore_releaseoutput.go | 26 +- wallet/internal/db/tx.go | 4 +- .../db/tx_store_backend_error_test.go | 100 ++-- .../internal/db/tx_store_backend_rows_test.go | 66 +-- wallet/internal/db/tx_store_common.go | 429 +++++++++-------- wallet/internal/db/tx_store_common_test.go | 447 +++++++++--------- .../db/tx_store_invalidateunmined_common.go | 86 ++-- .../tx_store_invalidateunmined_common_test.go | 160 ++++--- wallet/internal/db/utxo_store_common.go | 88 ++-- wallet/internal/db/utxo_store_common_test.go | 179 +++---- wallet/internal/db/wallet_pg.go | 8 +- wallet/internal/db/wallet_sqlite.go | 6 +- wallet/internal/db/wallets_common.go | 4 +- 58 files changed, 1487 insertions(+), 1516 deletions(-) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 24324e5786..2bb6317261 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -9,10 +9,10 @@ import ( ) var ( - // errNilDBAccountNumber is returned when the database returns a nil account + // ErrNilDBAccountNumber is returned when the database returns a nil account // number. In practice, this should never happen, but it's possible if the // database is modified incorrectly or the query is incorrect. - errNilDBAccountNumber = errors.New("database returned nil account number") + ErrNilDBAccountNumber = errors.New("database returned nil account number") // errInvalidAccountOrigin is returned when an account origin ID from the // database does not correspond to a known AccountOrigin value. In practice, @@ -48,9 +48,9 @@ func (params *CreateImportedAccountParams) isWatchOnly() bool { return len(params.EncryptedPrivateKey) == 0 } -// accountPropsRow represents the raw database fields needed to construct +// AccountPropsRow represents the raw database fields needed to construct // AccountProperties. -type accountPropsRow[AddrTypeId, AccOriginId any] struct { +type AccountPropsRow[AddrTypeId, AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId @@ -74,17 +74,17 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { func getKeyCounts(external, internal, imported int64) (uint32, uint32, uint32, error) { - externalKeyCount, err := int64ToUint32(external) + externalKeyCount, err := Int64ToUint32(external) if err != nil { return 0, 0, 0, fmt.Errorf("external key count: %w", err) } - internalKeyCount, err := int64ToUint32(internal) + internalKeyCount, err := Int64ToUint32(internal) if err != nil { return 0, 0, 0, fmt.Errorf("internal key count: %w", err) } - importedKeyCount, err := int64ToUint32(imported) + importedKeyCount, err := Int64ToUint32(imported) if err != nil { return 0, 0, 0, fmt.Errorf("imported key count: %w", err) } @@ -95,7 +95,7 @@ func getKeyCounts(external, internal, imported int64) (uint32, uint32, // getAddrTypes extracts the internal and external address types from the row // and handles errors. func getAddrTypes[AddrTypeId, AccOriginId any]( - row accountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, + row AccountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, error) { internalType, err := row.IDToAddrType(row.InternalTypeID) @@ -111,18 +111,18 @@ func getAddrTypes[AddrTypeId, AccOriginId any]( return internalType, externalType, nil } -// accountPropsRowToProps converts a database row containing full account +// AccountPropsRowToProps converts a database row containing full account // properties into an AccountProperties struct. The idToAddrType function is // used to convert the internal and external address type IDs to AddressType // values. -func accountPropsRowToProps[AddrTypeId, AccOriginId any]( - row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { +func AccountPropsRowToProps[AddrTypeId, AccOriginId any]( + row AccountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { var accountNum uint32 if row.AccountNumber.Valid { var err error - accountNum, err = int64ToUint32(row.AccountNumber.Int64) + accountNum, err = Int64ToUint32(row.AccountNumber.Int64) if err != nil { return nil, fmt.Errorf("account number: %w", err) } @@ -133,12 +133,12 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( return nil, fmt.Errorf("origin: %w", err) } - purposeNum, err := int64ToUint32(row.Purpose) + purposeNum, err := Int64ToUint32(row.Purpose) if err != nil { return nil, fmt.Errorf("purpose: %w", err) } - coinTypeNum, err := int64ToUint32(row.CoinType) + coinTypeNum, err := Int64ToUint32(row.CoinType) if err != nil { return nil, fmt.Errorf("coin type: %w", err) } @@ -150,7 +150,7 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( var fingerprint uint32 if row.MasterFingerprint.Valid { - fingerprint, err = int64ToUint32(row.MasterFingerprint.Int64) + fingerprint, err = Int64ToUint32(row.MasterFingerprint.Int64) if err != nil { return nil, fmt.Errorf("master fingerprint: %w", err) } @@ -203,9 +203,9 @@ func accountInfosFromRows[T any](rows []T, return accounts, nil } -// listAccounts is a generic helper that retrieves accounts using the provided +// ListAccounts is a generic helper that retrieves accounts using the provided // list function and converts the results to AccountInfo structs. -func listAccounts[T any, Args any](ctx context.Context, +func ListAccounts[T any, Args any](ctx context.Context, lister func(context.Context, Args) ([]T, error), args Args, toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { @@ -229,12 +229,12 @@ func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { return addrSchema, nil } -// buildAccountInfo creates an AccountInfo with the provided values and zeroed +// BuildAccountInfo creates an AccountInfo with the provided values and zeroed // balances while we do not yet support balance tracking. // // TODO(stingelin): Add balance tracking support after transaction management is // implemented. -func buildAccountInfo(accountNum uint32, accountName string, +func BuildAccountInfo(accountNum uint32, accountName string, origin AccountOrigin, externalKeyCount, internalKeyCount, importedKeyCount uint32, isWatchOnly bool, createdAt time.Time, scope KeyScope) *AccountInfo { @@ -254,9 +254,9 @@ func buildAccountInfo(accountNum uint32, accountName string, } } -// idToAccountOrigin safely converts an integer to AccountOrigin. It returns an +// IDToAccountOrigin safely converts an integer to AccountOrigin. It returns an // error if the value does not correspond to a known AccountOrigin value. -func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { +func IDToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { if v < 0 || v > T(ImportedAccount) { return 0, fmt.Errorf("%w: %d", errInvalidAccountOrigin, v) } @@ -264,9 +264,9 @@ func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { return AccountOrigin(v), nil } -// accountInfoRow represents the raw database fields needed to construct +// AccountInfoRow represents the raw database fields needed to construct // AccountInfo. -type accountInfoRow[AccOriginId any] struct { +type AccountInfoRow[AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId @@ -280,16 +280,16 @@ type accountInfoRow[AccOriginId any] struct { IDToOriginType func(AccOriginId) (AccountOrigin, error) } -// accountRowToInfo converts raw database field values into an AccountInfo +// AccountRowToInfo converts raw database field values into an AccountInfo // struct. It handles type conversion and validation for each field. -func accountRowToInfo[AccOriginId any]( - row accountInfoRow[AccOriginId]) (*AccountInfo, error) { +func AccountRowToInfo[AccOriginId any]( + row AccountInfoRow[AccOriginId]) (*AccountInfo, error) { var accountNum uint32 if row.AccountNumber.Valid { var err error - accountNum, err = int64ToUint32(row.AccountNumber.Int64) + accountNum, err = Int64ToUint32(row.AccountNumber.Int64) if err != nil { return nil, fmt.Errorf("account number: %w", err) } @@ -300,12 +300,12 @@ func accountRowToInfo[AccOriginId any]( return nil, fmt.Errorf("origin: %w", err) } - purposeNum, err := int64ToUint32(row.Purpose) + purposeNum, err := Int64ToUint32(row.Purpose) if err != nil { return nil, fmt.Errorf("purpose: %w", err) } - coinTypeNum, err := int64ToUint32(row.CoinType) + coinTypeNum, err := Int64ToUint32(row.CoinType) if err != nil { return nil, fmt.Errorf("coin type: %w", err) } @@ -317,16 +317,16 @@ func accountRowToInfo[AccOriginId any]( return nil, err } - return buildAccountInfo( + return BuildAccountInfo( accountNum, row.AccountName, origin, externalKeyCount, internalKeyCount, importedKeyCount, row.IsWatchOnly, row.CreatedAt, KeyScope{Purpose: purposeNum, Coin: coinTypeNum}, ), nil } -// ensureKeyScope retrieves an existing key scope or creates it if missing. It +// EnsureKeyScope retrieves an existing key scope or creates it if missing. It // returns the scope ID once available. -func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( +func EnsureKeyScope[Row any, GetArgs any, CreateArgs any]( ctx context.Context, getter func(context.Context, GetArgs) (Row, error), getArgs GetArgs, creator func(context.Context, CreateArgs) (int64, error), createArgs func(ScopeAddrSchema) CreateArgs, rowToID func(Row) int64, @@ -373,13 +373,13 @@ func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( return rowToID(scopeInfo), nil } -// getAccountFunc defines a function signature for retrieving a single account. -type getAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) +// GetAccountFunc defines a function signature for retrieving a single account. +type GetAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) -// getAccountByQuery dispatches to the appropriate query based on the provided +// GetAccountByQuery dispatches to the appropriate query based on the provided // account identifier. -func getAccountByQuery(ctx context.Context, query GetAccountQuery, - getByNumber getAccountFunc, getByName getAccountFunc) (*AccountInfo, +func GetAccountByQuery(ctx context.Context, query GetAccountQuery, + getByNumber GetAccountFunc, getByName GetAccountFunc) (*AccountInfo, error) { switch { @@ -394,16 +394,16 @@ func getAccountByQuery(ctx context.Context, query GetAccountQuery, } } -// listAccountsFunc defines a function signature for listing accounts. -type listAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, +// ListAccountsFunc defines a function signature for listing accounts. +type ListAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, error) -// listAccountsByQuery dispatches to the appropriate list query based on the +// ListAccountsByQuery dispatches to the appropriate list query based on the // provided filters. It returns an error if both scope and name filters are // provided, as they are mutually exclusive. -func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, - listByScope listAccountsFunc, listByName listAccountsFunc, - listAll listAccountsFunc) ([]AccountInfo, error) { +func ListAccountsByQuery(ctx context.Context, query ListAccountsQuery, + listByScope ListAccountsFunc, listByName ListAccountsFunc, + listAll ListAccountsFunc) ([]AccountInfo, error) { switch { case query.Scope != nil && query.Name != nil: @@ -420,13 +420,13 @@ func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, } } -// renameAccountFunc defines a function signature for renaming an account. -type renameAccountFunc func(context.Context, RenameAccountParams) error +// RenameAccountFunc defines a function signature for renaming an account. +type RenameAccountFunc func(context.Context, RenameAccountParams) error -// renameAccountByQuery dispatches to the appropriate rename query based on the +// RenameAccountByQuery dispatches to the appropriate rename query based on the // provided account identifier (either account number or old name). -func renameAccountByQuery(ctx context.Context, params RenameAccountParams, - renameByNumber renameAccountFunc, renameByName renameAccountFunc) error { +func RenameAccountByQuery(ctx context.Context, params RenameAccountParams, + renameByNumber RenameAccountFunc, renameByName RenameAccountFunc) error { if params.NewName == "" { return ErrMissingAccountName @@ -444,10 +444,10 @@ func renameAccountByQuery(ctx context.Context, params RenameAccountParams, } } -// getAccount is a generic helper that retrieves an account using the provided +// GetAccount is a generic helper that retrieves an account using the provided // query function. It handles error mapping and delegates conversion to the // toInfo function. -func getAccount[T any, Args any](ctx context.Context, +func GetAccount[T any, Args any](ctx context.Context, getter func(context.Context, Args) (T, error), args Args, query GetAccountQuery, toInfo func(T) (*AccountInfo, error)) (*AccountInfo, error) { @@ -471,10 +471,10 @@ func getAccount[T any, Args any](ctx context.Context, ErrAccountNotFound) } -// renameAccount is a generic helper that updates an account name using the +// RenameAccount is a generic helper that updates an account name using the // provided update function. It checks rows affected and returns an error if // the account was not found. -func renameAccount[Args any](ctx context.Context, +func RenameAccount[Args any](ctx context.Context, update func(context.Context, Args) (int64, error), args Args, params RenameAccountParams) error { @@ -496,11 +496,11 @@ func renameAccount[Args any](ctx context.Context, params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) } -// createImportedAccount is a generic helper that creates an imported account. +// CreateImportedAccount is a generic helper that creates an imported account. // It handles ensuring the key scope exists, creating the account record, // optionally creating the account secret for non-watch-only accounts, and // fetching the full account properties from the database. -func createImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( +func CreateImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( ctx context.Context, params CreateImportedAccountParams, ensureScope func() (int64, error), createAccount func(context.Context, CreateArgs) (CreateRow, error), diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index e42f494cb7..72c2f54860 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -18,7 +18,7 @@ func (s *PostgresStore) GetAccount(ctx context.Context, getQueries := pgAccountGetQueries{q: s.queries} - return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -28,7 +28,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, listQueries := pgAccountListQueries{q: s.queries} - return listAccountsByQuery( + return ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -40,7 +40,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, renameQueries := pgAccountRenameQueries{q: s.queries} - return renameAccountByQuery( + return RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -91,15 +91,15 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return errNilDBAccountNumber + return ErrNilDBAccountNumber } - accNumber, err := int64ToUint32(row.AccountNumber.Int64) + accNumber, err := Int64ToUint32(row.AccountNumber.Int64) if err != nil { return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) } - info = buildAccountInfo( + info = BuildAccountInfo( accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -125,7 +125,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error - props, err = createImportedAccount( + props, err = CreateImportedAccount( ctx, params, func() (int64, error) { return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, @@ -193,7 +193,7 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, return nil, fmt.Errorf("get account props: %w", err) } - return accountPropsRowToProps(accountPropsRow[int16, int16]{ + return AccountPropsRowToProps(AccountPropsRow[int16, int16]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -208,8 +208,8 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: idToAddressType[int16], - IDToOriginType: idToAccountOrigin[int16], + IDToAddrType: IDToAddressType[int16], + IDToOriginType: IDToAccountOrigin[int16], }) } @@ -218,7 +218,7 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, scope KeyScope) (int64, error) { - return ensureKeyScope( + return EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcpg.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), @@ -266,7 +266,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) - return accountRowToInfo(accountInfoRow[int16]{ + return AccountRowToInfo(AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -277,7 +277,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int16], + IDToOriginType: IDToAccountOrigin[int16], }) } @@ -290,7 +290,7 @@ type pgAccountListQueries struct { func (p pgAccountListQueries) byScope(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWalletScope, sqlcpg.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -304,7 +304,7 @@ func (p pgAccountListQueries) byScope(ctx context.Context, func (p pgAccountListQueries) byName(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWalletAndName, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -317,7 +317,7 @@ func (p pgAccountListQueries) byName(ctx context.Context, func (p pgAccountListQueries) all(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), pgAccountRowToInfo, ) @@ -332,13 +332,13 @@ type pgAccountGetQueries struct { func (p pgAccountGetQueries) byNumber(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } @@ -347,7 +347,7 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, func (p pgAccountGetQueries) byName(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, sqlcpg.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), @@ -368,14 +368,14 @@ type pgAccountRenameQueries struct { func (p pgAccountRenameQueries) byNumber(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -385,7 +385,7 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, func (p pgAccountRenameQueries) byName(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index d73d0f18cc..d61a46111d 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -18,7 +18,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, getQueries := sqliteAccountGetQueries{q: s.queries} - return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -28,7 +28,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, listQueries := sqliteAccountListQueries{q: s.queries} - return listAccountsByQuery( + return ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -40,7 +40,7 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, renameQueries := sqliteAccountRenameQueries{q: s.queries} - return renameAccountByQuery( + return RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -81,15 +81,15 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return errNilDBAccountNumber + return ErrNilDBAccountNumber } - accNumber, err := int64ToUint32(row.AccountNumber.Int64) + accNumber, err := Int64ToUint32(row.AccountNumber.Int64) if err != nil { return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) } - info = buildAccountInfo( + info = BuildAccountInfo( accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -108,7 +108,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, scope KeyScope) (int64, error) { - return ensureKeyScope( + return EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), @@ -145,7 +145,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error - props, err = createImportedAccount( + props, err = CreateImportedAccount( ctx, params, func() (int64, error) { return sqliteEnsureKeyScope( @@ -219,7 +219,7 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, return nil, fmt.Errorf("get account props: %w", err) } - return accountPropsRowToProps(accountPropsRow[int64, int64]{ + return AccountPropsRowToProps(AccountPropsRow[int64, int64]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -234,8 +234,8 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: idToAddressType[int64], - IDToOriginType: idToAccountOrigin[int64], + IDToAddrType: IDToAddressType[int64], + IDToOriginType: IDToAccountOrigin[int64], }) } @@ -262,7 +262,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAccountByScopeAndNameRow(row) - return accountRowToInfo(accountInfoRow[int64]{ + return AccountRowToInfo(AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -273,7 +273,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int64], + IDToOriginType: IDToAccountOrigin[int64], }) } @@ -286,7 +286,7 @@ type sqliteAccountListQueries struct { func (s sqliteAccountListQueries) byScope(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWalletScope, sqlcsqlite.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -300,7 +300,7 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, func (s sqliteAccountListQueries) byName(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWalletAndName, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -313,7 +313,7 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, func (s sqliteAccountListQueries) all(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), sqliteAccountRowToInfo, ) @@ -328,13 +328,13 @@ type sqliteAccountGetQueries struct { func (s sqliteAccountGetQueries) byNumber(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } @@ -343,7 +343,7 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, func (s sqliteAccountGetQueries) byName(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount(ctx, s.q.GetAccountByWalletScopeAndName, + return GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -363,14 +363,14 @@ type sqliteAccountRenameQueries struct { func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -380,7 +380,7 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, func (s sqliteAccountRenameQueries) byName(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go index 044f245203..9914c66f42 100644 --- a/wallet/internal/db/address_types_common.go +++ b/wallet/internal/db/address_types_common.go @@ -13,9 +13,9 @@ import ( // incorrect. var errInvalidAddressType = errors.New("invalid address type") -// idToAddressType safely converts an integer to AddressType. It returns an +// IDToAddressType safely converts an integer to AddressType. It returns an // error if the value does not correspond to a known AddressType value. -func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { +func IDToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { if v < 0 || v > T(Anchor) { return 0, fmt.Errorf("%w: %d", errInvalidAddressType, v) } @@ -23,9 +23,9 @@ func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { return AddressType(v), nil } -// listAddressTypes is a generic helper that retrieves all address types from +// ListAddressTypes is a generic helper that retrieves all address types from // the database and converts them to AddressTypeInfo structs. -func listAddressTypes[Row any](ctx context.Context, +func ListAddressTypes[Row any](ctx context.Context, lister func(context.Context) ([]Row, error), toInfo func(Row) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { @@ -48,10 +48,10 @@ func listAddressTypes[Row any](ctx context.Context, return types, nil } -// getAddressTypeByID is a generic helper that retrieves a single address type +// GetAddressTypeByID is a generic helper that retrieves a single address type // by its ID and converts it to an AddressTypeInfo struct. It returns // ErrAddressTypeNotFound if no matching type is found. -func getAddressTypeByID[Row any, ID any](ctx context.Context, +func GetAddressTypeByID[Row any, ID any](ctx context.Context, getter func(context.Context, ID) (Row, error), queryID ID, id AddressType, toInfo func(Row) (AddressTypeInfo, error)) (AddressTypeInfo, error) { diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 52ee3d6111..f033ef9241 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -9,7 +9,7 @@ import ( // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - addrType, err := idToAddressType(row.ID) + addrType, err := IDToAddressType(row.ID) if err != nil { return AddressTypeInfo{}, err } @@ -25,7 +25,7 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes( + return ListAddressTypes( ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } @@ -35,7 +35,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( func (s *PostgresStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID( + return GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index 7120c86cde..28aaaf0433 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -11,7 +11,7 @@ import ( func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, error) { - addrType, err := idToAddressType(row.ID) + addrType, err := IDToAddressType(row.ID) if err != nil { return AddressTypeInfo{}, err } @@ -27,7 +27,7 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes( + return ListAddressTypes( ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } @@ -37,7 +37,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( func (s *SqliteStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID( + return GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e3e059d531..db022a33a1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -27,41 +27,41 @@ var ( errInvalidDerivationPath = errors.New("invalid derivation path") ) -// accountLookupKey contains the fields needed to look up an account. -type accountLookupKey struct { - walletID int64 - purpose int64 - coinType int64 - accountName string +// AccountLookupKey contains the fields needed to look up an account. +type AccountLookupKey struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string } -// accountKeyFromParams extracts account lookup fields from params. -func accountKeyFromParams(params NewDerivedAddressParams) accountLookupKey { - return accountLookupKey{ - walletID: int64(params.WalletID), - purpose: int64(params.Scope.Purpose), - coinType: int64(params.Scope.Coin), - accountName: params.AccountName, +// AccountKeyFromParams extracts account lookup fields from params. +func AccountKeyFromParams(params NewDerivedAddressParams) AccountLookupKey { + return AccountLookupKey{ + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountName: params.AccountName, } } -// accountKeyFromImportedParams extracts account lookup fields from imported +// AccountKeyFromImportedParams extracts account lookup fields from imported // params using DefaultImportedAccountName. -func accountKeyFromImportedParams( - params NewImportedAddressParams) accountLookupKey { +func AccountKeyFromImportedParams( + params NewImportedAddressParams) AccountLookupKey { - return accountLookupKey{ - walletID: int64(params.WalletID), - purpose: int64(params.Scope.Purpose), - coinType: int64(params.Scope.Coin), - accountName: DefaultImportedAccountName, + return AccountLookupKey{ + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountName: DefaultImportedAccountName, } } -// getAddressSecret is a generic helper that retrieves address secret +// GetAddressSecret is a generic helper that retrieves address secret // information using the provided getter function and converts it to an // AddressSecret with error handling. -func getAddressSecret[Row any](ctx context.Context, +func GetAddressSecret[Row any](ctx context.Context, getter func(context.Context, int64) (Row, error), addressID uint32, toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { @@ -101,9 +101,9 @@ func (p NewImportedAddressParams) isWatchOnly() bool { return false } -// idToOrigin safely converts an integer to AccountOrigin. It returns an error +// IDToOrigin safely converts an integer to AccountOrigin. It returns an error // if the value is outside [DerivedAccount, ImportedAccount]. -func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { +func IDToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { if v < 0 || v > T(ImportedAccount) { return 0, fmt.Errorf("address origin: %d: %w", v, errInvalidOriginID) } @@ -111,10 +111,10 @@ func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { return AccountOrigin(uint8(v)), nil } -// addressInfoRow captures common fields from all address row types across +// AddressInfoRow captures common fields from all address row types across // PostgreSQL and SQLite backends. Uses generic type parameters to handle // different ID types (int16 for PostgreSQL, int64 for SQLite). -type addressInfoRow[TypeID, OriginIDType any] struct { +type AddressInfoRow[TypeID, OriginIDType any] struct { // ID is the database unique identifier for the address. ID int64 @@ -158,9 +158,9 @@ type addressInfoRow[TypeID, OriginIDType any] struct { IDToOrigin func(OriginIDType) (AccountOrigin, error) } -// addressSecretRow captures fields shared by address secret row types across +// AddressSecretRow captures fields shared by address secret row types across // backends. -type addressSecretRow struct { +type AddressSecretRow struct { // AddressID is the database unique identifier for the address. AddressID int64 @@ -171,9 +171,9 @@ type addressSecretRow struct { EncryptedScript []byte } -// addressSecretRowToSecret converts raw secret row fields into an AddressSecret +// AddressSecretRowToSecret converts raw secret row fields into an AddressSecret // with validation and ID conversion. -func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { +func AddressSecretRowToSecret(row AddressSecretRow) (*AddressSecret, error) { hasKey := len(row.EncryptedPrivKey) > 0 hasScript := len(row.EncryptedScript) > 0 @@ -182,7 +182,7 @@ func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { ErrSecretNotFound) } - addrID, err := int64ToUint32(row.AddressID) + addrID, err := Int64ToUint32(row.AddressID) if err != nil { return nil, fmt.Errorf("address ID: %w", err) } @@ -197,12 +197,12 @@ func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { // convertAddressIDs converts database IDs to their respective uint32 values // with error handling. func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { - addrID, err := int64ToUint32(id) + addrID, err := Int64ToUint32(id) if err != nil { return 0, 0, fmt.Errorf("address ID: %w", err) } - acctID, err := int64ToUint32(accountID) + acctID, err := Int64ToUint32(accountID) if err != nil { return 0, 0, fmt.Errorf("account ID: %w", err) } @@ -254,7 +254,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( // convertAddressMetadata converts address type and origin IDs with error // handling. func convertAddressMetadata[TypeID, OriginIDType any]( - row addressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, + row AddressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, error) { addrType, err := row.IDToAddrType(row.TypeID) @@ -288,12 +288,12 @@ func convertAddressPath(origin AccountOrigin, branch, return 0, 0, errInvalidDerivationPath } - addrBranch, err := int64ToUint32(branch.Int64) + addrBranch, err := Int64ToUint32(branch.Int64) if err != nil { return 0, 0, fmt.Errorf("address branch: %w", err) } - addrIndex, err := int64ToUint32(index.Int64) + addrIndex, err := Int64ToUint32(index.Int64) if err != nil { return 0, 0, fmt.Errorf("address index: %w", err) } @@ -304,7 +304,7 @@ func convertAddressPath(origin AccountOrigin, branch, // addressRowToInfo converts raw database field values into an AddressInfo // struct. It handles type conversion and validation for each field. func addressRowToInfo[TypeID, OriginIDType any]( - row addressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { + row AddressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { id, accountID, err := convertAddressIDs(row.ID, row.AccountID) if err != nil { @@ -340,10 +340,10 @@ func addressRowToInfo[TypeID, OriginIDType any]( }, nil } -// getAddress is a generic helper that retrieves a single address using the +// GetAddress is a generic helper that retrieves a single address using the // provided getter function. It handles sql.ErrNoRows mapping and delegates // conversion to the toInfo function. -func getAddress[T any, Args any](ctx context.Context, +func GetAddress[T any, Args any](ctx context.Context, getter func(context.Context, Args) (T, error), args Args, toInfo func(T) (*AddressInfo, error)) (*AddressInfo, error) { @@ -359,9 +359,9 @@ func getAddress[T any, Args any](ctx context.Context, return nil, ErrAddressNotFound } -// nextListAddressesQuery returns a query with its pagination cursor advanced to +// NextListAddressesQuery returns a query with its pagination cursor advanced to // the provided value. -func nextListAddressesQuery(q ListAddressesQuery, +func NextListAddressesQuery(q ListAddressesQuery, cursor uint32) ListAddressesQuery { q.Page = q.Page.WithAfter(cursor) @@ -369,78 +369,78 @@ func nextListAddressesQuery(q ListAddressesQuery, return q } -// derivedAddressAdapters groups the functions needed to create a +// DerivedAddressAdapters groups the functions needed to create a // derived address across different database backends. -type derivedAddressAdapters[QTX any, AccountRow any, AccountParams any, +type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, AddrRow any] struct { - // getAccount retrieves an account by the provided parameters. - getAccount func(context.Context, AccountParams) (AccountRow, error) + // GetAccount retrieves an account by the provided parameters. + GetAccount func(context.Context, AccountParams) (AccountRow, error) - // accountParams converts params to account lookup parameters. - accountParams func(NewDerivedAddressParams) AccountParams + // AccountParams converts params to account lookup parameters. + AccountParams func(NewDerivedAddressParams) AccountParams - // getAccountID extracts the account ID from an account row. - getAccountID func(AccountRow) int64 + // GetAccountID extracts the account ID from an account row. + GetAccountID func(AccountRow) int64 - // getExtIndex returns a function to get the external index. - getExtIndex func(QTX) func(context.Context, int64) (int64, error) + // GetExtIndex returns a function to get the external index. + GetExtIndex func(QTX) func(context.Context, int64) (int64, error) - // getIntIndex returns a function to get the internal index. - getIntIndex func(QTX) func(context.Context, int64) (int64, error) + // GetIntIndex returns a function to get the internal index. + GetIntIndex func(QTX) func(context.Context, int64) (int64, error) - // createAddr returns a function to create an address row. - createAddr func(QTX) func(context.Context, int64, AddressType, uint32, + // CreateAddr returns a function to create an address row. + CreateAddr func(QTX) func(context.Context, int64, AddressType, uint32, uint32, []byte) (AddrRow, error) - // rowID extracts the ID from an address row. - rowID func(AddrRow) int64 + // RowID extracts the ID from an address row. + RowID func(AddrRow) int64 - // rowCreatedAt extracts the creation time from an address row. - rowCreatedAt func(AddrRow) time.Time + // RowCreatedAt extracts the creation time from an address row. + RowCreatedAt func(AddrRow) time.Time } -// importedAddressAdapters groups the functions needed to create an +// ImportedAddressAdapters groups the functions needed to create an // imported address across different database backends. -type importedAddressAdapters[QTX any, AccountRow any, +type ImportedAddressAdapters[QTX any, AccountRow any, AccountParams any, CreateArgs any, AddrRow any, SecretParams any] struct { - // getAccount retrieves an account by the provided parameters. - getAccount func(context.Context, AccountParams) (AccountRow, error) + // GetAccount retrieves an account by the provided parameters. + GetAccount func(context.Context, AccountParams) (AccountRow, error) - // accountParams converts params to account lookup parameters. - accountParams func(NewImportedAddressParams) AccountParams + // AccountParams converts params to account lookup parameters. + AccountParams func(NewImportedAddressParams) AccountParams - // getAccountID extracts the account ID from an account row. - getAccountID func(AccountRow) int64 + // GetAccountID extracts the account ID from an account row. + GetAccountID func(AccountRow) int64 - // createAddr returns a function to create an address row. - createAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) + // CreateAddr returns a function to create an address row. + CreateAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) - // createParams converts accountID and params to address creation + // CreateParams converts accountID and params to address creation // parameters. - createParams func(int64, NewImportedAddressParams) CreateArgs + CreateParams func(int64, NewImportedAddressParams) CreateArgs - // insertSecret returns a function to insert address secret. - insertSecret func(QTX) func(context.Context, SecretParams) error + // InsertSecret returns a function to insert address secret. + InsertSecret func(QTX) func(context.Context, SecretParams) error - // secretParams converts address ID and params to secret parameters. - secretParams func(int64, NewImportedAddressParams) SecretParams + // SecretParams converts address ID and params to secret parameters. + SecretParams func(int64, NewImportedAddressParams) SecretParams - // rowID extracts the ID from an address row. - rowID func(AddrRow) int64 + // RowID extracts the ID from an address row. + RowID func(AddrRow) int64 - // rowCreatedAt extracts the creation time from an address row. - rowCreatedAt func(AddrRow) time.Time + // RowCreatedAt extracts the creation time from an address row. + RowCreatedAt func(AddrRow) time.Time } -// getAddressFunc defines a function signature for retrieving a single address. -type getAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) +// GetAddressFunc defines a function signature for retrieving a single address. +type GetAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) -// getAddressByQuery validates the query and executes the script-based lookup. -func getAddressByQuery(ctx context.Context, query GetAddressQuery, - getter getAddressFunc) (*AddressInfo, error) { +// GetAddressByQuery validates the query and executes the script-based lookup. +func GetAddressByQuery(ctx context.Context, query GetAddressQuery, + getter GetAddressFunc) (*AddressInfo, error) { if len(query.ScriptPubKey) == 0 { return nil, ErrInvalidAddressQuery @@ -534,13 +534,13 @@ func derivedAddressInput(ctx context.Context, return 0, 0, 0, nil, ErrMaxAddressIndexReached } - index, err := int64ToUint32(indexValue) + index, err := Int64ToUint32(indexValue) if err != nil { return 0, 0, 0, nil, fmt.Errorf( "address index: %w", err) } - acctID, err := int64ToUint32(accountID) + acctID, err := Int64ToUint32(accountID) if err != nil { return 0, 0, 0, nil, fmt.Errorf( "account ID: %w", err) @@ -555,27 +555,27 @@ func derivedAddressInput(ctx context.Context, return addrType, branch, index, derivedData.ScriptPubKey, nil } -// newDerivedAddressWithTx combines transaction execution, account lookup, +// NewDerivedAddressWithTx combines transaction execution, account lookup, // and derived address creation in one helper. -func newDerivedAddressWithTx[QTX any, AccountRow any, +func NewDerivedAddressWithTx[QTX any, AccountRow any, AccountParams any, AddrRow any](ctx context.Context, params NewDerivedAddressParams, executeTx func(context.Context, func(QTX) error) error, - adapters derivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], + adapters DerivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], deriveFn AddressDerivationFunc) (*AddressInfo, error) { var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { - row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { info, errAddr := createDerivedAddress( - ctx, params, adapters.getAccountID(row), - adapters.getExtIndex(qtx), - adapters.getIntIndex(qtx), - adapters.createAddr(qtx), - adapters.rowID, - adapters.rowCreatedAt, deriveFn) + ctx, params, adapters.GetAccountID(row), + adapters.GetExtIndex(qtx), + adapters.GetIntIndex(qtx), + adapters.CreateAddr(qtx), + adapters.RowID, + adapters.RowCreatedAt, deriveFn) if errAddr != nil { return errAddr } @@ -586,10 +586,11 @@ func newDerivedAddressWithTx[QTX any, AccountRow any, } if errors.Is(err, sql.ErrNoRows) { - key := accountKeyFromParams(params) + key := AccountKeyFromParams(params) return fmt.Errorf("account %q in scope %d/%d: %w", - key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + key.AccountName, key.Purpose, key.CoinType, + ErrAccountNotFound) } return fmt.Errorf("get account: %w", err) @@ -601,13 +602,13 @@ func newDerivedAddressWithTx[QTX any, AccountRow any, return result, nil } -// newImportedAddressWithTx combines transaction execution, account lookup, +// NewImportedAddressWithTx combines transaction execution, account lookup, // and imported address creation in one helper. -func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, +func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, CreateArgs any, AddrRow any, SecretParams any]( ctx context.Context, params NewImportedAddressParams, executeTx func(context.Context, func(QTX) error) error, - adapters importedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, + adapters ImportedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, AddrRow, SecretParams]) (*AddressInfo, error) { validationErr := params.validate() @@ -618,17 +619,17 @@ func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { - row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { - acctID := adapters.getAccountID(row) + acctID := adapters.GetAccountID(row) info, errAddr := newImportedAddressTx( - ctx, adapters.createAddr(qtx), - adapters.createParams(acctID, params), - adapters.insertSecret, qtx, - adapters.secretParams, params, acctID, - adapters.rowID, - adapters.rowCreatedAt) + ctx, adapters.CreateAddr(qtx), + adapters.CreateParams(acctID, params), + adapters.InsertSecret, qtx, + adapters.SecretParams, params, acctID, + adapters.RowID, + adapters.RowCreatedAt) if errAddr != nil { return errAddr } @@ -639,10 +640,11 @@ func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, } if errors.Is(err, sql.ErrNoRows) { - key := accountKeyFromImportedParams(params) + key := AccountKeyFromImportedParams(params) return fmt.Errorf("account %q in scope %d/%d: %w", - key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + key.AccountName, key.Purpose, key.CoinType, + ErrAccountNotFound) } return fmt.Errorf("get account: %w", err) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 7159261ac5..0d01de2b03 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -21,7 +21,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { - return getAddress( + return GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, @@ -30,7 +30,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, ) } - return getAddressByQuery(ctx, query, getByScript) + return GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. @@ -57,7 +57,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, query ListAddressesQuery) iter.Seq2[AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, nextListAddressesQuery, + ctx, query, s.ListAddresses, NextListAddressesQuery, ) } @@ -65,7 +65,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { - return getAddressSecret( + return GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } @@ -76,63 +76,63 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { - adapters := derivedAddressAdapters[ + adapters := DerivedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - getAccount: pgGetAccountFromKey(s.queries), - accountParams: accountKeyFromParams, - getAccountID: newDerivedAddressGetAccountIDPg, - getExtIndex: newDerivedAddressGetExtIndexPg, - getIntIndex: newDerivedAddressGetIntIndexPg, - createAddr: newDerivedAddressCreateAddrPg, - rowID: newDerivedAddressRowIDPg, - rowCreatedAt: newDerivedAddressRowCreatedAtPg, + GetAccount: pgGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromParams, + GetAccountID: newDerivedAddressGetAccountIDPg, + GetExtIndex: newDerivedAddressGetExtIndexPg, + GetIntIndex: newDerivedAddressGetIntIndexPg, + CreateAddr: newDerivedAddressCreateAddrPg, + RowID: newDerivedAddressRowIDPg, + RowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *PostgresStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { - adapters := importedAddressAdapters[ + adapters := ImportedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - getAccount: pgGetAccountFromKey(s.queries), - accountParams: accountKeyFromImportedParams, - getAccountID: newImportedAddressGetAccountIDPg, - createAddr: pgCreateImportedAddress, - createParams: createImportedAddressParamsPg, - insertSecret: pgInsertAddressSecret, - secretParams: insertAddressSecretParamsPg, - rowID: importedAddressRowIDPg, - rowCreatedAt: importedAddressRowCreatedAtPg, + GetAccount: pgGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromImportedParams, + GetAccountID: newImportedAddressGetAccountIDPg, + CreateAddr: pgCreateImportedAddress, + CreateParams: createImportedAddressParamsPg, + InsertSecret: pgInsertAddressSecret, + SecretParams: insertAddressSecretParamsPg, + RowID: importedAddressRowIDPg, + RowCreatedAt: importedAddressRowCreatedAtPg, } - return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ - WalletID: key.walletID, - Purpose: key.purpose, - CoinType: key.coinType, - AccountName: key.accountName, + WalletID: key.WalletID, + Purpose: key.Purpose, + CoinType: key.CoinType, + AccountName: key.AccountName, }, ) } @@ -168,7 +168,7 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { - branchNum, err := uint32ToInt16(branch) + branchNum, err := Uint32ToInt16(branch) if err != nil { return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, @@ -268,7 +268,7 @@ func importedAddressRowCreatedAtPg( func pgAddressSecretRowToSecret( row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { - return addressSecretRowToSecret(addressSecretRow{ + return AddressSecretRowToSecret(AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -290,7 +290,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(addressInfoRow[int16, int16]{ + info, err := addressRowToInfo(AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -305,8 +305,8 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: idToAddressType[int16], - IDToOrigin: idToOrigin[int16], + IDToAddrType: IDToAddressType[int16], + IDToOrigin: IDToOrigin[int16], }) if err != nil { return nil, err diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index 0e1bbc500e..5645297197 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -21,7 +21,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { - return getAddress( + return GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), @@ -30,7 +30,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, ) } - return getAddressByQuery(ctx, query, getByScript) + return GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. @@ -57,7 +57,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, query ListAddressesQuery) iter.Seq2[AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, nextListAddressesQuery, + ctx, query, s.ListAddresses, NextListAddressesQuery, ) } @@ -65,7 +65,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, func (s *SqliteStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { - return getAddressSecret( + return GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) @@ -77,63 +77,63 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { - adapters := derivedAddressAdapters[ + adapters := DerivedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - getAccount: sqliteGetAccountFromKey(s.queries), - accountParams: accountKeyFromParams, - getAccountID: newDerivedAddressGetAccountIDSQLite, - getExtIndex: newDerivedAddressGetExtIndexSQLite, - getIntIndex: newDerivedAddressGetIntIndexSQLite, - createAddr: newDerivedAddressCreateAddrSQLite, - rowID: newDerivedAddressRowIDSQLite, - rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + GetAccount: sqliteGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromParams, + GetAccountID: newDerivedAddressGetAccountIDSQLite, + GetExtIndex: newDerivedAddressGetExtIndexSQLite, + GetIntIndex: newDerivedAddressGetIntIndexSQLite, + CreateAddr: newDerivedAddressCreateAddrSQLite, + RowID: newDerivedAddressRowIDSQLite, + RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *SqliteStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { - adapters := importedAddressAdapters[ + adapters := ImportedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - getAccount: sqliteGetAccountFromKey(s.queries), - accountParams: accountKeyFromImportedParams, - getAccountID: newImportedAddressGetAccountIDSQLite, - createAddr: sqliteCreateImportedAddress, - createParams: createImportedAddressParamsSQLite, - insertSecret: sqliteInsertAddressSecret, - secretParams: insertAddressSecretParamsSQLite, - rowID: importedAddressRowIDSQLite, - rowCreatedAt: importedAddressRowCreatedAtSQLite, + GetAccount: sqliteGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromImportedParams, + GetAccountID: newImportedAddressGetAccountIDSQLite, + CreateAddr: sqliteCreateImportedAddress, + CreateParams: createImportedAddressParamsSQLite, + InsertSecret: sqliteInsertAddressSecret, + SecretParams: insertAddressSecretParamsSQLite, + RowID: importedAddressRowIDSQLite, + RowCreatedAt: importedAddressRowCreatedAtSQLite, } - return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ - WalletID: key.walletID, - Purpose: key.purpose, - CoinType: key.coinType, - AccountName: key.accountName, + WalletID: key.WalletID, + Purpose: key.Purpose, + CoinType: key.CoinType, + AccountName: key.AccountName, }, ) } @@ -264,7 +264,7 @@ func insertAddressSecretParamsSQLite(addressID int64, func sqliteAddressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { - return addressSecretRowToSecret(addressSecretRow{ + return AddressSecretRowToSecret(AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -287,7 +287,7 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(addressInfoRow[int64, int64]{ + info, err := addressRowToInfo(AddressInfoRow[int64, int64]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -299,8 +299,8 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: idToAddressType[int64], - IDToOrigin: idToOrigin[int64], + IDToAddrType: IDToAddressType[int64], + IDToOrigin: IDToOrigin[int64], }) if err != nil { return nil, err diff --git a/wallet/internal/db/block_common.go b/wallet/internal/db/block_common.go index 0aafd3fb35..6473965f05 100644 --- a/wallet/internal/db/block_common.go +++ b/wallet/internal/db/block_common.go @@ -7,9 +7,9 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" ) -// buildBlock constructs a Block from the provided components that are common +// BuildBlock constructs a Block from the provided components that are common // across different database backends. -func buildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { +func BuildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { h, err := chainhash.NewHash(hash) if err != nil { return nil, fmt.Errorf("block hash: %w", err) diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index 7fee38f67c..b25aaf0172 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -15,19 +15,19 @@ import ( func buildPgBlock(height sql.NullInt32, hash []byte, timestamp sql.NullInt64) (*Block, error) { - height32, err := nullInt32ToUint32(height) + height32, err := NullInt32ToUint32(height) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return buildBlock(hash, height32, timestamp.Int64) + return BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsPg ensures that a block exists in the database. func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, block *Block) error { - height, err := uint32ToInt32(block.Height) + height, err := Uint32ToInt32(block.Height) if err != nil { return fmt.Errorf("convert block height: %w", err) } @@ -51,7 +51,7 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, block *Block) (int32, error) { - height, err := uint32ToInt32(block.Height) + height, err := Uint32ToInt32(block.Height) if err != nil { return 0, fmt.Errorf("convert block height: %w", err) } diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index ffa5413dd7..39bbf9916c 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -15,12 +15,12 @@ import ( func buildSqliteBlock(height sql.NullInt64, hash []byte, timestamp sql.NullInt64) (*Block, error) { - height32, err := int64ToUint32(height.Int64) + height32, err := Int64ToUint32(height.Int64) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return buildBlock(hash, height32, timestamp.Int64) + return BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsSqlite ensures that a block exists in the database. If it diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 7c9078c130..63ee0e195f 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -486,9 +486,9 @@ func TestListAccountsOrdering(t *testing.T) { scope := db.KeyScopeBIP0084 // Create accounts in mixed order: imported, derived, imported, derived. - createImportedAccount(t, store, walletID, scope, "imported-first") + CreateImportedAccount(t, store, walletID, scope, "imported-first") createDerivedAccount(t, store, walletID, scope, "derived-0") - createImportedAccount(t, store, walletID, scope, "imported-second") + CreateImportedAccount(t, store, walletID, scope, "imported-second") createDerivedAccount(t, store, walletID, scope, "derived-1") query := db.ListAccountsQuery{ @@ -793,10 +793,10 @@ func createDerivedAccount(t *testing.T, store db.AccountStore, walletID uint32, require.NoError(t, err) } -// createImportedAccount creates a new imported account with the given name, +// CreateImportedAccount creates a new imported account with the given name, // scope, and wallet ID using the provided account store. A random public key // is generated for the account. -func createImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, +func CreateImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, scope db.KeyScope, name string) { t.Helper() diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index b92465da21..80f7f42153 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -133,9 +133,9 @@ func TestNewImportedAddress(t *testing.T) { queries := store.Queries() walletID := newWallet(t, store, "wallet-imported-addresses") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -252,7 +252,7 @@ func TestNewImportedAddress(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := getAddressSecret(t, queries, addressID) + secret, err := GetAddressSecret(t, queries, addressID) require.NoError(t, err) if tc.providePrivateKey { require.Equal( @@ -276,8 +276,8 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { store := NewTestStore(t) queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount( + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount( t, store, walletID, db.KeyScopeBIP0049Plus, "imported", ) @@ -365,7 +365,7 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := getAddressSecret(t, queries, addressID) + secret, err := GetAddressSecret(t, queries, addressID) require.NoError(t, err) require.Equal(t, tc.encryptedScript, secret.EncryptedScript) @@ -387,7 +387,7 @@ func TestImportedAddressCounterInsertDelete(t *testing.T) { store := NewTestStore(t) dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") const importedAddrCount = 5 addressIDs := make([]uint32, 0, importedAddrCount) @@ -435,7 +435,7 @@ func TestImportedAddressCounterConcurrentInsert(t *testing.T) { store := NewTestStore(t) dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter-concurrent") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") const workers = 20 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -517,7 +517,7 @@ func TestNewImportedAddressDuplicate(t *testing.T) { store := NewTestStore(t) walletID := newWallet(t, store, "wallet-duplicate-import") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") // Set up encryption parameters (same for both imports). scriptPubKey := RandomBytes(32) @@ -550,7 +550,7 @@ func TestGetAddressSecret(t *testing.T) { store := NewTestStore(t) walletID := newWallet(t, store, "wallet-secrets") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") testCases := []struct { name string @@ -628,7 +628,7 @@ func TestGetAddress(t *testing.T) { accountStore db.AccountStore, walletID uint32) db.GetAddressQuery { - createImportedAccount( + CreateImportedAccount( t, accountStore, walletID, db.KeyScopeBIP0084, "imported", ) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index d496f98c55..b7d0a902f2 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -146,7 +146,7 @@ func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, return addr.ID } -func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, +func GetAddressSecret(t *testing.T, queries *sqlcpg.Queries, addressID int64) (sqlcpg.GetAddressSecretRow, error) { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 8e3a9d568f..de5a4783bd 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -146,7 +146,7 @@ func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, return addr.ID } -func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, +func GetAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { t.Helper() diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 60ac84fd3e..2441ba1265 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -81,7 +81,7 @@ func (s *PostgresStore) Close() error { func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return execInTx(ctx, s.db, func(tx *sql.Tx) error { + return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index a22b40ed1d..dee3a8bfb7 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -17,19 +17,19 @@ import ( // The full write runs inside ExecuteTx so the transaction row, created UTXOs, // spent-parent markers, and any required invalidation are either committed // together or not at all. Received timestamps are normalized to UTC before -// insert. When the wallet already stores the same unmined transaction hash, +// Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, params CreateTxParams) error { - req, err := newCreateTxRequest(params) + req, err := NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return createTxWithOps(ctx, req, &pgCreateTxOps{ + return CreateTxWithOps(ctx, req, &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,46 +44,46 @@ type pgCreateTxOps struct { blockHeight sql.NullInt32 } -var _ createTxOps = (*pgCreateTxOps)(nil) +var _ CreateTxOps = (*pgCreateTxOps)(nil) -// loadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *pgCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *pgCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errCreateTxExistingNotFound + return nil, ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(meta.TxStatus)) + status, err := ParseTxStatus(int64(meta.TxStatus)) if err != nil { return nil, err } - return &createTxExistingTarget{ - id: meta.ID, - status: status, - hasBlock: meta.BlockHeight.Valid, - isCoinbase: meta.IsCoinbase, + return &CreateTxExistingTarget{ + ID: meta.ID, + Status: status, + HasBlock: meta.BlockHeight.Valid, + IsCoinbase: meta.IsCoinbase, }, nil } -// confirmExisting promotes one existing unmined row to its confirmed state. -func (o *pgCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, - _ createTxExistingTarget) error { +// ConfirmExisting promotes one existing unmined row to its confirmed state. +func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, + _ CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -92,8 +92,8 @@ func (o *pgCreateTxOps) confirmExisting(ctx context.Context, ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, Status: int16(TxStatusPublished), - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { @@ -101,24 +101,24 @@ func (o *pgCreateTxOps) confirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) } return nil } -// prepareBlock validates the optional confirming block and caches the postgres -// block-height value that the later insert query will store. -func (o *pgCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock validates the optional confirming block and caches the postgres +// block-height value that the later Insert query will store. +func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { o.blockHeight = sql.NullInt32{} - if req.params.Block == nil { + if req.Params.Block == nil { return nil } - height, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + height, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -128,10 +128,10 @@ func (o *pgCreateTxOps) prepareBlock(ctx context.Context, return nil } -// listConflictTxns returns the direct conflict root IDs plus the matching tx +// ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -142,7 +142,7 @@ func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, return nil, nil, nil } - rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.Params.WalletID)) if err != nil { return nil, nil, fmt.Errorf("list unmined txns: %w", err) } @@ -153,15 +153,15 @@ func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, // collectPgConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, - req createTxRequest) (map[int64]struct{}, error) { + req CreateTxRequest) (map[int64]struct{}, error) { - if blockchain.IsCoinBaseTx(req.params.Tx) { + if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil } - rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) - for inputIndex, txIn := range req.params.Tx.TxIn { - outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) + for inputIndex, txIn := range req.Params.Tx.TxIn { + outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return nil, fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) @@ -169,7 +169,7 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, spentByTxID, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlcpg.GetUtxoSpendByOutpointParams{ - WalletID: int64(req.params.WalletID), + WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, }, @@ -219,44 +219,44 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, return rootIDs, rootHashes, nil } -// insert stores one new postgres transaction row for CreateTx. -func (o *pgCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert stores one new postgres transaction row for CreateTx. +func (o *pgCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], - RawTx: req.rawTx, + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], + RawTx: req.RawTx, BlockHeight: o.blockHeight, - TxStatus: int16(req.params.Status), - ReceivedTime: req.received, - IsCoinbase: req.isCoinbase, - TxLabel: req.params.Label, + TxStatus: int16(req.Params.Status), + ReceivedTime: req.Received, + IsCoinbase: req.IsCoinbase, + TxLabel: req.Params.Label, }) if err != nil { - return 0, fmt.Errorf("insert tx row: %w", err) + return 0, fmt.Errorf("Insert tx row: %w", err) } return txID, nil } -// insertCredits stores any wallet-owned outputs created by the transaction. -func (o *pgCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits stores any wallet-owned outputs created by the transaction. +func (o *pgCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { - return insertCreditsPg(ctx, o.qtx, req.params, txID) + return insertCreditsPg(ctx, o.qtx, req.Params, txID) } -// markInputsSpent records wallet-owned inputs spent by the transaction. -func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent records wallet-owned inputs spent by the transaction. +func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { - return markInputsSpentPg(ctx, o.qtx, req.params, txID) + return markInputsSpentPg(ctx, o.qtx, req.Params, txID) } -// markTxnsReplaced marks the provided direct conflict roots replaced in one +// MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *pgCreateTxOps) markTxnsReplaced( +func (o *pgCreateTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -273,9 +273,9 @@ func (o *pgCreateTxOps) markTxnsReplaced( return nil } -// insertReplacementEdges records replacement-history edges from each direct +// InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *pgCreateTxOps) insertReplacementEdges( +func (o *pgCreateTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -288,7 +288,7 @@ func (o *pgCreateTxOps) insertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("insert replacement edge for %d: %w", + return fmt.Errorf("Insert replacement edge for %d: %w", replacedTxID, err) } } @@ -330,7 +330,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("resolve credit address %d: %w", index, err) } - outputIndex, err := uint32ToInt32(index) + outputIndex, err := Uint32ToInt32(index) if err != nil { return fmt.Errorf("convert credit index %d: %w", index, err) } @@ -343,7 +343,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return fmt.Errorf("Insert credit output %d: %w", index, err) } } @@ -355,7 +355,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { - convertedIndex, err := uint32ToInt32(outputIndex) + convertedIndex, err := Uint32ToInt32(outputIndex) if err != nil { return false, fmt.Errorf("convert credit index %d: %w", outputIndex, err) @@ -396,13 +396,13 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, } for inputIndex, txIn := range params.Tx.TxIn { - outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) } - spentInputIndex, err := int64ToInt32(int64(inputIndex)) + spentInputIndex, err := Int64ToInt32(int64(inputIndex)) if err != nil { return fmt.Errorf("convert input index %d: %w", inputIndex, err) } diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/postgres_txstore_deletetx.go index bc157e725d..c9182d2eb8 100644 --- a/wallet/internal/db/postgres_txstore_deletetx.go +++ b/wallet/internal/db/postgres_txstore_deletetx.go @@ -21,7 +21,7 @@ func (s *PostgresStore) DeleteTx(ctx context.Context, params DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return deleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) }) } @@ -30,11 +30,11 @@ type pgDeleteTxOps struct { qtx *sqlcpg.Queries } -var _ deleteTxOps = (*pgDeleteTxOps)(nil) +var _ DeleteTxOps = (*pgDeleteTxOps)(nil) -// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) @@ -45,17 +45,17 @@ func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, return meta.ID, nil } -// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o pgDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) } -// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -72,9 +72,9 @@ func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, return nil } -// deleteCreatedUtxos removes any wallet-owned outputs created by the +// DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, +func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -91,9 +91,9 @@ func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, return nil } -// deleteUnminedTransaction removes the target unmined row after its dependent +// DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o pgDeleteTxOps) deleteUnminedTransaction(ctx context.Context, +func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -121,7 +121,7 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := buildUnminedTxRecords(rows, + candidates, err := BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -132,14 +132,14 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, filtered := candidates[:0] for _, candidate := range candidates { - if candidate.id == txID { + if candidate.ID == txID { continue } filtered = append(filtered, candidate) } - if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresLeaf) } @@ -169,12 +169,12 @@ func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(meta.TxStatus)) + status, err := ParseTxStatus(int64(meta.TxStatus)) if err != nil { return sqlcpg.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !isUnminedStatus(status) { + if meta.BlockHeight.Valid || !IsUnminedStatus(status) { return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresUnmined) diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/postgres_txstore_gettx.go index 80f0c56955..4e154e0ad8 100644 --- a/wallet/internal/db/postgres_txstore_gettx.go +++ b/wallet/internal/db/postgres_txstore_gettx.go @@ -57,5 +57,5 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, } } - return buildTxInfo(hash, rawTx, received, block, status, label) + return BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/postgres_txstore_invalidateunmined.go index 20d3b34818..49b007e88e 100644 --- a/wallet/internal/db/postgres_txstore_invalidateunmined.go +++ b/wallet/internal/db/postgres_txstore_invalidateunmined.go @@ -16,7 +16,7 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, params InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return invalidateUnminedTxWithOps( + return InvalidateUnminedTxWithOps( ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +28,12 @@ type pgInvalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ invalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) -// loadInvalidateTarget loads the root tx metadata used by the shared +// LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { +func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ @@ -43,48 +43,48 @@ func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) } - return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(row.TxStatus)) + status, err := ParseTxStatus(int64(row.TxStatus)) if err != nil { - return invalidateUnminedTxTarget{}, err + return InvalidateUnminedTxTarget{}, err } - return invalidateUnminedTxTarget{ - id: row.ID, - txHash: txHash, - status: status, - hasBlock: row.BlockHeight.Valid, - isCoinbase: row.IsCoinbase, + return InvalidateUnminedTxTarget{ + ID: row.ID, + TxHash: txHash, + Status: status, + HasBlock: row.BlockHeight.Valid, + IsCoinbase: row.IsCoinbase, }, nil } -// listUnminedTxRecords loads and decodes the wallet's active unmined +// ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o pgInvalidateUnminedTxOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) } -// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -103,9 +103,9 @@ func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return nil } -// markTxnsFailed marks the provided tx rows failed in one +// MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o pgInvalidateUnminedTxOps) markTxnsFailed( +func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/postgres_txstore_listtxns.go index d6977c3364..36ed4ed816 100644 --- a/wallet/internal/db/postgres_txstore_listtxns.go +++ b/wallet/internal/db/postgres_txstore_listtxns.go @@ -57,12 +57,12 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) { - startHeight, err := uint32ToInt32(query.StartHeight) + startHeight, err := Uint32ToInt32(query.StartHeight) if err != nil { return nil, fmt.Errorf("convert start height: %w", err) } - endHeight, err := uint32ToInt32(query.EndHeight) + endHeight, err := Uint32ToInt32(query.EndHeight) if err != nil { return nil, fmt.Errorf("convert end height: %w", err) } @@ -89,7 +89,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, err } - info, err := buildTxInfo( + info, err := BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, int64(row.TxStatus), row.TxLabel, ) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index f4b9b2ec4b..dec0984047 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -16,7 +16,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return rollbackToBlockWithOps(ctx, height, + return RollbackToBlockWithOps(ctx, height, pgRollbackToBlockOps{qtx: qtx}) }) } @@ -27,14 +27,14 @@ type pgRollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) -// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, +func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return nil, fmt.Errorf("convert rollback height: %w", err) } @@ -47,9 +47,9 @@ func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, return groupRollbackCoinbaseRootsPg(rows) } -// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( +func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { // PostgreSQL stores block heights as INTEGER today, so rollback still needs @@ -60,14 +60,14 @@ func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( // // TODO(yy): Fix it when we are in year 42000, which will give us 800 years // before it's reached. - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } newHeight := sql.NullInt32{} if height > 0 { - newHeight, err = uint32ToNullInt32(height - 1) + newHeight, err = Uint32ToNullInt32(height - 1) if err != nil { return fmt.Errorf("convert new height: %w", err) } @@ -86,12 +86,12 @@ func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( return nil } -// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( +func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } @@ -104,9 +104,9 @@ func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } -// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, +func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { for _, txHash := range rootHashes { @@ -134,26 +134,26 @@ func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, return nil } -// listUnminedTxRecords loads and decodes every unmined transaction row for the +// ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o pgRollbackToBlockOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o pgRollbackToBlockOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) } -// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o pgRollbackToBlockOps) clearDescendantSpends( +func (o pgRollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -172,9 +172,9 @@ func (o pgRollbackToBlockOps) clearDescendantSpends( return nil } -// markDescendantsFailed batch-marks the collected rollback descendants as +// MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o pgRollbackToBlockOps) markDescendantsFailed( +func (o pgRollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -200,7 +200,7 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := int64ToUint32(row.WalletID) + walletID, err := Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/postgres_txstore_updatetx.go index dbbe6ca180..81d4905686 100644 --- a/wallet/internal/db/postgres_txstore_updatetx.go +++ b/wallet/internal/db/postgres_txstore_updatetx.go @@ -20,7 +20,7 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, params UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return updateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) }) } @@ -38,11 +38,11 @@ type pgUpdateTxOps struct { status int16 } -var _ updateTxOps = (*pgUpdateTxOps)(nil) +var _ UpdateTxOps = (*pgUpdateTxOps)(nil) -// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, +func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -63,9 +63,9 @@ func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, return meta.IsCoinbase, nil } -// prepareState validates any referenced confirming block and captures the +// PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. -func (o *pgUpdateTxOps) prepareState(ctx context.Context, +func (o *pgUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { blockHeight := sql.NullInt32{} @@ -85,9 +85,9 @@ func (o *pgUpdateTxOps) prepareState(ctx context.Context, return nil } -// updateState writes one block/status patch after prepareState has validated +// UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, +func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( @@ -110,8 +110,8 @@ func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, return nil } -// updateLabel writes one user-visible label change. -func (o *pgUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel writes one user-visible label change. +func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go index 5826e64dd7..05d88fd37b 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *PostgresStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32ToSQLInt64(params.Account), - MinConfirms: nullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: nullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: nullableInt32ToSQLInt32(params.CoinbaseMaturity), + AccountNumber: NullableUint32ToSQLInt64(params.Account), + MinConfirms: NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: NullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/postgres_utxostore_getutxo.go index 6ffac436e5..b55ed91759 100644 --- a/wallet/internal/db/postgres_utxostore_getutxo.go +++ b/wallet/internal/db/postgres_utxostore_getutxo.go @@ -17,7 +17,7 @@ import ( func (s *PostgresStore) GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) { - outputIndex, err := uint32ToInt32(query.OutPoint.Index) + outputIndex, err := Uint32ToInt32(query.OutPoint.Index) if err != nil { return nil, fmt.Errorf("convert output index: %w", err) } @@ -50,14 +50,14 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt32) (*UtxoInfo, error) { - index, err := int64ToUint32(int64(outputIndex)) + index, err := Int64ToUint32(int64(outputIndex)) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := nullInt32ToUint32(blockHeight) + heightValue, err := NullInt32ToUint32(blockHeight) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -65,7 +65,7 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, height = &heightValue } - return buildUtxoInfo( + return BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/postgres_utxostore_leaseoutput.go index e3592f5493..05e0c7f624 100644 --- a/wallet/internal/db/postgres_utxostore_leaseoutput.go +++ b/wallet/internal/db/postgres_utxostore_leaseoutput.go @@ -14,14 +14,14 @@ import ( // // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are -// normalized to UTC before insert. +// normalized to UTC before Insert. func (s *PostgresStore) LeaseOutput(ctx context.Context, params LeaseOutputParams) (*LeasedOutput, error) { var lease *LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - acquiredLease, err := leaseOutputWithOps( + acquiredLease, err := LeaseOutputWithOps( ctx, params, &pgLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,15 +45,15 @@ type pgLeaseOutputOps struct { qtx *sqlcpg.Queries } -var _ leaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*pgLeaseOutputOps)(nil) -// acquire attempts to write or renew one postgres lease row for the requested +// Acquire attempts to write or renew one postgres lease row for the requested // outpoint. -func (o *pgLeaseOutputOps) acquire(ctx context.Context, +func (o *pgLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return time.Time{}, fmt.Errorf("convert output index: %w", err) } @@ -73,18 +73,18 @@ func (o *pgLeaseOutputOps) acquire(ctx context.Context, } if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, errLeaseOutputNoRow + return time.Time{}, ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) } -// hasUtxo reports whether the requested outpoint still exists as a current +// HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *pgLeaseOutputOps) hasUtxo(ctx context.Context, +func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return false, fmt.Errorf("convert output index: %w", err) } diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go index 1944d7790e..79f8d522f2 100644 --- a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go @@ -26,12 +26,12 @@ func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, leases := make([]LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := int64ToUint32(int64(row.OutputIndex)) + outputIndex, err := Int64ToUint32(int64(row.OutputIndex)) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go index 2a9afdeeb3..fbbb76666b 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -40,8 +40,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32ToSQLInt64(query.Account), - MinConfirms: nullableInt32ToSQLInt32(query.MinConfs), - MaxConfirms: nullableInt32ToSQLInt32(query.MaxConfs), + AccountNumber: NullableUint32ToSQLInt64(query.Account), + MinConfirms: NullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: NullableInt32ToSQLInt32(query.MaxConfs), } } diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/postgres_utxostore_releaseoutput.go index 579118dd17..5557e03a5b 100644 --- a/wallet/internal/db/postgres_utxostore_releaseoutput.go +++ b/wallet/internal/db/postgres_utxostore_releaseoutput.go @@ -19,7 +19,7 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return releaseOutputWithOps( + return ReleaseOutputWithOps( ctx, params, &pgReleaseOutputOps{qtx: qtx}, ) }) @@ -31,14 +31,14 @@ type pgReleaseOutputOps struct { qtx *sqlcpg.Queries } -var _ releaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*pgReleaseOutputOps)(nil) -// lookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO +// LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. -func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, +func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return 0, err } @@ -52,7 +52,7 @@ func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, errReleaseOutputUtxoNotFound + return 0, ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -61,9 +61,9 @@ func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, nil } -// release attempts to delete the postgres lease row for the provided UTXO ID +// Release attempts to delete the postgres lease row for the provided UTXO ID // and lock ID. -func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, +func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -74,18 +74,18 @@ func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("release lease row: %w", err) + return 0, fmt.Errorf("Release lease row: %w", err) } return rows, nil } -// activeLockID returns the currently active postgres lease lock ID for the +// ActiveLockID returns the currently active postgres lease lock ID for the // provided UTXO ID. -func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, +func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -94,11 +94,11 @@ func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errReleaseOutputNoActiveLease + return nil, ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return activeLockID, nil + return ActiveLockID, nil } diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 5188a5f608..ac878eb8d6 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -17,9 +17,9 @@ var ( ErrInvalidNullInt = errors.New("invalid NullInt") ) -// int64ToUint32 safely casts an int64 to an uint32, returning an error +// Int64ToUint32 safely casts an int64 to an uint32, returning an error // if the value is out of range. -func int64ToUint32(v int64) (uint32, error) { +func Int64ToUint32(v int64) (uint32, error) { if v < 0 || v > math.MaxUint32 { return 0, fmt.Errorf("could not cast %d to uint32: %w", v, ErrCastingOverflow) @@ -28,9 +28,9 @@ func int64ToUint32(v int64) (uint32, error) { return uint32(v), nil } -// int64ToInt32 safely casts an int64 to an int32, returning an error +// Int64ToInt32 safely casts an int64 to an int32, returning an error // if the value is out of range. -func int64ToInt32(v int64) (int32, error) { +func Int64ToInt32(v int64) (int32, error) { if v < math.MinInt32 || v > math.MaxInt32 { return 0, fmt.Errorf("could not cast %d to int32: %w", v, ErrCastingOverflow) @@ -50,9 +50,9 @@ func int64ToUint8(v int64) (uint8, error) { return uint8(v), nil } -// int16ToUint8 safely casts an int16 to an uint8, returning an error +// Int16ToUint8 safely casts an int16 to an uint8, returning an error // if the value is out of range. -func int16ToUint8(v int16) (uint8, error) { +func Int16ToUint8(v int16) (uint8, error) { if v < 0 || v > math.MaxUint8 { return 0, fmt.Errorf("could not cast %d to uint8: %w", v, ErrCastingOverflow) @@ -61,9 +61,9 @@ func int16ToUint8(v int16) (uint8, error) { return uint8(v), nil } -// uint32ToInt32 safely casts an uint32 to an int32, returning an error +// Uint32ToInt32 safely casts an uint32 to an int32, returning an error // if the value is out of range. -func uint32ToInt32(v uint32) (int32, error) { +func Uint32ToInt32(v uint32) (int32, error) { if v > math.MaxInt32 { return 0, fmt.Errorf("could not cast %d to int32: %w", v, ErrCastingOverflow) @@ -72,9 +72,9 @@ func uint32ToInt32(v uint32) (int32, error) { return int32(v), nil } -// uint32ToInt16 safely casts an uint32 to an int16, returning an error +// Uint32ToInt16 safely casts an uint32 to an int16, returning an error // if the value is out of range. -func uint32ToInt16(v uint32) (int16, error) { +func Uint32ToInt16(v uint32) (int16, error) { if v > math.MaxInt16 { return 0, fmt.Errorf("could not cast %d to int16: %w", v, ErrCastingOverflow) @@ -83,10 +83,10 @@ func uint32ToInt16(v uint32) (int16, error) { return int16(v), nil } -// uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning +// Uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning // an error if the value is out of range. -func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { - toInt32, err := uint32ToInt32(v) +func Uint32ToNullInt32(v uint32) (sql.NullInt32, error) { + toInt32, err := Uint32ToInt32(v) if err != nil { return sql.NullInt32{}, err } @@ -94,8 +94,8 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { return sql.NullInt32{Int32: toInt32, Valid: true}, nil } -// nullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. -func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { +// NullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. +func NullableInt32ToSQLInt32(v *int32) sql.NullInt32 { if v == nil { return sql.NullInt32{} } @@ -103,8 +103,8 @@ func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { return sql.NullInt32{Int32: *v, Valid: true} } -// nullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. -func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { +// NullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. +func NullableInt32ToSQLInt64(v *int32) sql.NullInt64 { if v == nil { return sql.NullInt64{} } @@ -112,8 +112,8 @@ func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { return sql.NullInt64{Int64: int64(*v), Valid: true} } -// nullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. -func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { +// NullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. +func NullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { if v == nil { return sql.NullInt64{} } @@ -121,9 +121,9 @@ func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { return sql.NullInt64{Int64: int64(*v), Valid: true} } -// nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning +// NullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning // an error if the value is out of range or invalid. -func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { +func NullInt32ToUint32(n sql.NullInt32) (uint32, error) { if !n.Valid { return 0, fmt.Errorf("could not cast invalid NullInt32 to uint32: %w", ErrInvalidNullInt) diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 4523127a62..8b5360b5b3 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -38,7 +38,7 @@ func TestInt64ToUint32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int64ToUint32(tc.val) + got, err := Int64ToUint32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -88,7 +88,7 @@ func TestInt64ToInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int64ToInt32(tc.val) + got, err := Int64ToInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -188,7 +188,7 @@ func TestInt16ToUint8(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int16ToUint8(tc.val) + got, err := Int16ToUint8(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -225,7 +225,7 @@ func TestUint32ToInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToInt32(tc.val) + got, err := Uint32ToInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -262,7 +262,7 @@ func TestUint32ToInt16(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToInt16(tc.val) + got, err := Uint32ToInt16(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -310,7 +310,7 @@ func TestUint32ToNullInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToNullInt32(tc.val) + got, err := Uint32ToNullInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -360,7 +360,7 @@ func TestNullInt32ToUint32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := nullInt32ToUint32(tc.val) + got, err := NullInt32ToUint32(tc.val) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return @@ -379,10 +379,10 @@ func TestNullableInt32ToSQLInt32(t *testing.T) { value := int32(42) - require.Equal(t, sql.NullInt32{}, nullableInt32ToSQLInt32(nil)) + require.Equal(t, sql.NullInt32{}, NullableInt32ToSQLInt32(nil)) require.Equal(t, sql.NullInt32{Int32: value, Valid: true}, - nullableInt32ToSQLInt32(&value), + NullableInt32ToSQLInt32(&value), ) } @@ -393,10 +393,10 @@ func TestNullableInt32ToSQLInt64(t *testing.T) { value := int32(42) - require.Equal(t, sql.NullInt64{}, nullableInt32ToSQLInt64(nil)) + require.Equal(t, sql.NullInt64{}, NullableInt32ToSQLInt64(nil)) require.Equal(t, sql.NullInt64{Int64: int64(value), Valid: true}, - nullableInt32ToSQLInt64(&value), + NullableInt32ToSQLInt64(&value), ) } @@ -407,9 +407,9 @@ func TestNullableUint32ToSQLInt64(t *testing.T) { value := uint32(42) - require.Equal(t, sql.NullInt64{}, nullableUint32ToSQLInt64(nil)) + require.Equal(t, sql.NullInt64{}, NullableUint32ToSQLInt64(nil)) require.Equal(t, sql.NullInt64{Int64: int64(value), Valid: true}, - nullableUint32ToSQLInt64(&value), + NullableUint32ToSQLInt64(&value), ) } diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 53e582f761..e12ec6dc13 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -87,7 +87,7 @@ func (s *SqliteStore) Close() error { func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return execInTx(ctx, s.db, func(tx *sql.Tx) error { + return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 2b6aa5d56f..531c4579a7 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -17,19 +17,19 @@ import ( // The full write runs inside ExecuteTx so the transaction row, created UTXOs, // spent-parent markers, and any required invalidation are either committed // together or not at all. Received timestamps are normalized to UTC before -// insert. When the wallet already stores the same unmined transaction hash, +// Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, params CreateTxParams) error { - req, err := newCreateTxRequest(params) + req, err := NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return createTxWithOps(ctx, req, &sqliteCreateTxOps{ + return CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,46 +44,46 @@ type sqliteCreateTxOps struct { blockHeight sql.NullInt64 } -var _ createTxOps = (*sqliteCreateTxOps)(nil) +var _ CreateTxOps = (*sqliteCreateTxOps)(nil) -// loadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *sqliteCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errCreateTxExistingNotFound + return nil, ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(meta.TxStatus) + status, err := ParseTxStatus(meta.TxStatus) if err != nil { return nil, err } - return &createTxExistingTarget{ - id: meta.ID, - status: status, - hasBlock: meta.BlockHeight.Valid, - isCoinbase: meta.IsCoinbase, + return &CreateTxExistingTarget{ + ID: meta.ID, + Status: status, + HasBlock: meta.BlockHeight.Valid, + IsCoinbase: meta.IsCoinbase, }, nil } -// confirmExisting promotes one existing unmined row to its confirmed state. -func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, - _ createTxExistingTarget) error { +// ConfirmExisting promotes one existing unmined row to its confirmed state. +func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, + _ CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -92,8 +92,8 @@ func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, Status: int64(TxStatusPublished), - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { @@ -101,24 +101,24 @@ func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) } return nil } -// prepareBlock validates the optional confirming block and caches the sqlite -// block-height value that the later insert query will store. -func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock validates the optional confirming block and caches the sqlite +// block-height value that the later Insert query will store. +func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { o.blockHeight = sql.NullInt64{} - if req.params.Block == nil { + if req.Params.Block == nil { return nil } - height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -128,10 +128,10 @@ func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, return nil } -// listConflictTxns returns the direct conflict root IDs plus the matching tx +// ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -142,7 +142,7 @@ func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, return nil, nil, nil } - rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.Params.WalletID)) if err != nil { return nil, nil, fmt.Errorf("list unmined txns: %w", err) } @@ -154,17 +154,17 @@ func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectSqliteConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, - req createTxRequest) (map[int64]struct{}, error) { + req CreateTxRequest) (map[int64]struct{}, error) { - if blockchain.IsCoinBaseTx(req.params.Tx) { + if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil } - rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) - for inputIndex, txIn := range req.params.Tx.TxIn { + rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) + for inputIndex, txIn := range req.Params.Tx.TxIn { spentByTxID, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ - WalletID: int64(req.params.WalletID), + WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), }, @@ -214,47 +214,47 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, return rootIDs, rootHashes, nil } -// insert stores one new sqlite transaction row for CreateTx. -func (o *sqliteCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert stores one new sqlite transaction row for CreateTx. +func (o *sqliteCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( ctx, sqlcsqlite.InsertTransactionParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], - RawTx: req.rawTx, + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], + RawTx: req.RawTx, BlockHeight: o.blockHeight, - TxStatus: int64(req.params.Status), - ReceivedTime: req.received, - IsCoinbase: req.isCoinbase, - TxLabel: req.params.Label, + TxStatus: int64(req.Params.Status), + ReceivedTime: req.Received, + IsCoinbase: req.IsCoinbase, + TxLabel: req.Params.Label, }, ) if err != nil { - return 0, fmt.Errorf("insert tx row: %w", err) + return 0, fmt.Errorf("Insert tx row: %w", err) } return txID, nil } -// insertCredits stores any wallet-owned outputs created by the transaction. -func (o *sqliteCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits stores any wallet-owned outputs created by the transaction. +func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { - return insertCreditsSqlite(ctx, o.qtx, req.params, txID) + return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) } -// markInputsSpent records wallet-owned inputs spent by the transaction. -func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent records wallet-owned inputs spent by the transaction. +func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { - return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) + return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) } -// markTxnsReplaced marks the provided direct conflict roots replaced in one +// MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *sqliteCreateTxOps) markTxnsReplaced( +func (o *sqliteCreateTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -271,9 +271,9 @@ func (o *sqliteCreateTxOps) markTxnsReplaced( return nil } -// insertReplacementEdges records replacement-history edges from each direct +// InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *sqliteCreateTxOps) insertReplacementEdges( +func (o *sqliteCreateTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -286,7 +286,7 @@ func (o *sqliteCreateTxOps) insertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("insert replacement edge for %d: %w", + return fmt.Errorf("Insert replacement edge for %d: %w", replacedTxID, err) } } @@ -336,7 +336,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return fmt.Errorf("Insert credit output %d: %w", index, err) } } diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite_txstore_deletetx.go index 45d9f6ef6f..cbcc3969f0 100644 --- a/wallet/internal/db/sqlite_txstore_deletetx.go +++ b/wallet/internal/db/sqlite_txstore_deletetx.go @@ -21,7 +21,7 @@ func (s *SqliteStore) DeleteTx(ctx context.Context, params DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return deleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) }) } @@ -30,11 +30,11 @@ type sqliteDeleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ deleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ DeleteTxOps = (*sqliteDeleteTxOps)(nil) -// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, +func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) @@ -45,17 +45,17 @@ func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, return meta.ID, nil } -// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o sqliteDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, +func (o sqliteDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) } -// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, +func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -72,9 +72,9 @@ func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, return nil } -// deleteCreatedUtxos removes any wallet-owned outputs created by the +// DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, +func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -91,9 +91,9 @@ func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, return nil } -// deleteUnminedTransaction removes the target unmined row after its dependent +// DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o sqliteDeleteTxOps) deleteUnminedTransaction(ctx context.Context, +func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -121,7 +121,7 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := buildUnminedTxRecords( + candidates, err := BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, []byte, []byte) { @@ -135,14 +135,14 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, filtered := candidates[:0] for _, candidate := range candidates { - if candidate.id == txID { + if candidate.ID == txID { continue } filtered = append(filtered, candidate) } - if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresLeaf) } @@ -172,12 +172,12 @@ func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(meta.TxStatus) + status, err := ParseTxStatus(meta.TxStatus) if err != nil { return sqlcsqlite.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !isUnminedStatus(status) { + if meta.BlockHeight.Valid || !IsUnminedStatus(status) { return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresUnmined) diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite_txstore_gettx.go index c276800380..63c3382248 100644 --- a/wallet/internal/db/sqlite_txstore_gettx.go +++ b/wallet/internal/db/sqlite_txstore_gettx.go @@ -57,5 +57,5 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, } } - return buildTxInfo(hash, rawTx, received, block, status, label) + return BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite_txstore_invalidateunmined.go index 2403ab5f64..dcc229550b 100644 --- a/wallet/internal/db/sqlite_txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite_txstore_invalidateunmined.go @@ -16,7 +16,7 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, params InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return invalidateUnminedTxWithOps( + return InvalidateUnminedTxWithOps( ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +28,12 @@ type sqliteInvalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ invalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) -// loadInvalidateTarget loads the root tx metadata used by the shared +// LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { +func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ @@ -43,39 +43,39 @@ func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) } - return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(row.TxStatus) + status, err := ParseTxStatus(row.TxStatus) if err != nil { - return invalidateUnminedTxTarget{}, err + return InvalidateUnminedTxTarget{}, err } - return invalidateUnminedTxTarget{ - id: row.ID, - txHash: txHash, - status: status, - hasBlock: row.BlockHeight.Valid, - isCoinbase: row.IsCoinbase, + return InvalidateUnminedTxTarget{ + ID: row.ID, + TxHash: txHash, + Status: status, + HasBlock: row.BlockHeight.Valid, + IsCoinbase: row.IsCoinbase, }, nil } -// listUnminedTxRecords loads and decodes the wallet's active unmined +// ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords( + return BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -84,9 +84,9 @@ func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( ) } -// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -105,9 +105,9 @@ func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return nil } -// markTxnsFailed marks the provided tx rows failed in one +// MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o sqliteInvalidateUnminedTxOps) markTxnsFailed( +func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite_txstore_listtxns.go index cc6fb642ef..0a92e1ac66 100644 --- a/wallet/internal/db/sqlite_txstore_listtxns.go +++ b/wallet/internal/db/sqlite_txstore_listtxns.go @@ -79,7 +79,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, err } - info, err := buildTxInfo( + info, err := BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, row.TxLabel, ) diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index 42ec51be17..4bd62049e7 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -16,7 +16,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return rollbackToBlockWithOps(ctx, height, + return RollbackToBlockWithOps(ctx, height, sqliteRollbackToBlockOps{qtx: qtx}) }) } @@ -27,11 +27,11 @@ type sqliteRollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) -// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, +func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) @@ -42,9 +42,9 @@ func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, return groupRollbackCoinbaseRootsSqlite(rows) } -// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( +func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { newHeight := sql.NullInt64{} @@ -65,9 +65,9 @@ func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( return nil } -// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( +func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) @@ -78,9 +78,9 @@ func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } -// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( +func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { @@ -109,17 +109,17 @@ func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( return nil } -// listUnminedTxRecords loads and decodes every unmined transaction row for the +// ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o sqliteRollbackToBlockOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -128,9 +128,9 @@ func (o sqliteRollbackToBlockOps) listUnminedTxRecords( ) } -// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o sqliteRollbackToBlockOps) clearDescendantSpends( +func (o sqliteRollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -149,9 +149,9 @@ func (o sqliteRollbackToBlockOps) clearDescendantSpends( return nil } -// markDescendantsFailed batch-marks the collected rollback descendants as +// MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o sqliteRollbackToBlockOps) markDescendantsFailed( +func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -178,7 +178,7 @@ func groupRollbackCoinbaseRootsSqlite( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := int64ToUint32(row.WalletID) + walletID, err := Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite_txstore_updatetx.go index 71a9e397d7..c57d2104ca 100644 --- a/wallet/internal/db/sqlite_txstore_updatetx.go +++ b/wallet/internal/db/sqlite_txstore_updatetx.go @@ -20,7 +20,7 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, params UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return updateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) }) } @@ -38,11 +38,11 @@ type sqliteUpdateTxOps struct { status int64 } -var _ updateTxOps = (*sqliteUpdateTxOps)(nil) +var _ UpdateTxOps = (*sqliteUpdateTxOps)(nil) -// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, +func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -63,9 +63,9 @@ func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, return meta.IsCoinbase, nil } -// prepareState validates any referenced confirming block and captures the +// PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. -func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, +func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { blockHeight := sql.NullInt64{} @@ -85,8 +85,8 @@ func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, return nil } -// updateLabel writes one user-visible label change. -func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel writes one user-visible label change. +func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( @@ -108,9 +108,9 @@ func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, return nil } -// updateState writes one block/status patch after prepareState has validated +// UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *sqliteUpdateTxOps) updateState(ctx context.Context, walletID uint32, +func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go index 6ab7fd03fb..b19c11f6df 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *SqliteStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32ToSQLInt64(params.Account), - MinConfirms: nullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: nullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: nullableInt32ToSQLInt64(params.CoinbaseMaturity), + AccountNumber: NullableUint32ToSQLInt64(params.Account), + MinConfirms: NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: NullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite_utxostore_getutxo.go index dce91783e5..c5efca3f26 100644 --- a/wallet/internal/db/sqlite_utxostore_getutxo.go +++ b/wallet/internal/db/sqlite_utxostore_getutxo.go @@ -45,14 +45,14 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt64) (*UtxoInfo, error) { - index, err := int64ToUint32(outputIndex) + index, err := Int64ToUint32(outputIndex) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := int64ToUint32(blockHeight.Int64) + heightValue, err := Int64ToUint32(blockHeight.Int64) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -60,7 +60,7 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, height = &heightValue } - return buildUtxoInfo( + return BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite_utxostore_leaseoutput.go index e3033a27be..202de6f4f9 100644 --- a/wallet/internal/db/sqlite_utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite_utxostore_leaseoutput.go @@ -14,14 +14,14 @@ import ( // // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are -// normalized to UTC before insert. +// normalized to UTC before Insert. func (s *SqliteStore) LeaseOutput(ctx context.Context, params LeaseOutputParams) (*LeasedOutput, error) { var lease *LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - acquiredLease, err := leaseOutputWithOps( + acquiredLease, err := LeaseOutputWithOps( ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,11 +45,11 @@ type sqliteLeaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ leaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) -// acquire attempts to write or renew one sqlite lease row for the requested +// Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. -func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, +func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -65,18 +65,18 @@ func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, errLeaseOutputNoRow + return time.Time{}, ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) } return expiration, nil } -// hasUtxo reports whether the requested outpoint still exists as a current +// HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *sqliteLeaseOutputOps) hasUtxo(ctx context.Context, +func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go index 4be3fbfee3..63c4000470 100644 --- a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go @@ -26,12 +26,12 @@ func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, leases := make([]LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := int64ToUint32(row.OutputIndex) + outputIndex, err := Int64ToUint32(row.OutputIndex) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 741b4ce2ae..20119197a6 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -16,9 +16,9 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32ToSQLInt64(query.Account), - MinConfirms: nullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: nullableInt32ToSQLInt64(query.MaxConfs), + AccountNumber: NullableUint32ToSQLInt64(query.Account), + MinConfirms: NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: NullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite_utxostore_releaseoutput.go index 3ecc4d52bb..968ea65229 100644 --- a/wallet/internal/db/sqlite_utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite_utxostore_releaseoutput.go @@ -19,7 +19,7 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return releaseOutputWithOps( + return ReleaseOutputWithOps( ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, ) }) @@ -31,11 +31,11 @@ type sqliteReleaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ releaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) -// lookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row +// LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. -func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, +func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( @@ -47,7 +47,7 @@ func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, errReleaseOutputUtxoNotFound + return 0, ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -56,9 +56,9 @@ func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, nil } -// release attempts to delete the sqlite lease row for the provided UTXO ID and +// Release attempts to delete the sqlite lease row for the provided UTXO ID and // lock ID. -func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, +func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -69,18 +69,18 @@ func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("release lease row: %w", err) + return 0, fmt.Errorf("Release lease row: %w", err) } return rows, nil } -// activeLockID returns the currently active sqlite lease lock ID for the +// ActiveLockID returns the currently active sqlite lease lock ID for the // provided UTXO ID. -func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, +func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -89,11 +89,11 @@ func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errReleaseOutputNoActiveLease + return nil, ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return activeLockID, nil + return ActiveLockID, nil } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index a79ac45f31..ee34c55681 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -6,13 +6,13 @@ import ( "fmt" ) -// execInTx executes a function within a database transaction. It handles +// ExecInTx executes a function within a database transaction. It handles // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on // PostgresStore and SqliteStore. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). -func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { +func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 2918f47a62..3d51cd48b6 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -24,7 +24,7 @@ type errorDBTX struct { // ExecContext implements the sqlc DBTX interface. func (e errorDBTX) ExecContext(context.Context, string, - ...interface{}) (sql.Result, error) { + ...any) (sql.Result, error) { return nil, e.execErr } @@ -38,14 +38,14 @@ func (e errorDBTX) PrepareContext(context.Context, // QueryContext implements the sqlc DBTX interface. func (e errorDBTX) QueryContext(context.Context, string, - ...interface{}) (*sql.Rows, error) { + ...any) (*sql.Rows, error) { return nil, e.queryErr } // QueryRowContext implements the sqlc DBTX interface. func (e errorDBTX) QueryRowContext(context.Context, string, - ...interface{}) *sql.Row { + ...any) *sql.Row { return &sql.Row{} } @@ -63,24 +63,24 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { deleteOps := pgDeleteTxOps{qtx: qtx} rollbackOps := pgRollbackToBlockOps{qtx: qtx} - err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") - err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "delete created utxo rows") - _, err = deleteOps.deleteUnminedTransaction( + _, err = deleteOps.DeleteUnminedTransaction( t.Context(), 1, chainhash.Hash{1}, ) require.ErrorContains(t, err, "delete unmined tx row") - _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) require.ErrorContains(t, err, "clear descendant spends") - err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } @@ -94,30 +94,30 @@ func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { deleteOps := sqliteDeleteTxOps{qtx: qtx} rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") - err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "delete created utxo rows") - _, err = deleteOps.deleteUnminedTransaction( + _, err = deleteOps.DeleteUnminedTransaction( t.Context(), 1, chainhash.Hash{1}, ) require.ErrorContains(t, err, "delete unmined tx row") - _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) require.ErrorContains(t, err, "clear descendant spends") - err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } // TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper // adapters preserve step-specific error context for create, invalidate, -// rollback, update, and release workflows. +// rollback, update, and Release workflows. func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() @@ -130,15 +130,15 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { updateOps := &pgUpdateTxOps{qtx: qtx} releaseOps := pgReleaseOutputOps{qtx: qtx} - err := createOps.markTxnsReplaced( + err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, ) require.ErrorContains(t, err, "mark txns replaced") - err = createOps.insertReplacementEdges( + err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "insert replacement edge") + require.ErrorContains(t, err, "Insert replacement edge") err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ WalletID: 1, @@ -148,45 +148,45 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { }, 7) require.ErrorContains(t, err, "mark spent input 0") - _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxos") - err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns failed") - _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) require.ErrorContains(t, err, "query rollback coinbase roots") - err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) require.ErrorContains(t, err, "rewind wallet sync state heights query") - err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) require.ErrorContains(t, err, "delete blocks at or above height query") - err = rollbackOps.markTxRootsOrphaned( + err = rollbackOps.MarkTxRootsOrphaned( t.Context(), 1, []chainhash.Hash{{1}}, ) require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt32{} updateOps.status = int16(TxStatusPublished) - err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") - err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") - _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "release lease row") + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "Release lease row") } // TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store // helper adapters preserve step-specific error context for create, invalidate, -// rollback, update, and release workflows. +// rollback, update, and Release workflows. func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() @@ -201,15 +201,15 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { updateOps := &sqliteUpdateTxOps{qtx: qtx} releaseOps := sqliteReleaseOutputOps{qtx: qtx} - err := createOps.markTxnsReplaced( + err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, ) require.ErrorContains(t, err, "mark txns replaced") - err = createOps.insertReplacementEdges( + err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "insert replacement edge") + require.ErrorContains(t, err, "Insert replacement edge") err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ WalletID: 1, @@ -219,40 +219,40 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { }, 7) require.ErrorContains(t, err, "mark spent input 0") - _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxos") - err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns failed") - _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) require.ErrorContains(t, err, "query rollback coinbase roots") - err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) require.ErrorContains(t, err, "rewind wallet sync state heights query") - err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) require.ErrorContains(t, err, "delete blocks at or above height query") - err = rollbackOps.markTxRootsOrphaned( + err = rollbackOps.MarkTxRootsOrphaned( t.Context(), 1, []chainhash.Hash{{1}}, ) require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt64{} updateOps.status = int64(TxStatusPublished) - err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") - err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") - _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "release lease row") + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "Release lease row") } // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper @@ -260,7 +260,7 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { func TestPgBackendHelpersRejectOverflow(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -300,12 +300,12 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") - err = pgRollbackToBlockOps{}.rewindWalletSyncStateHeights( + err = pgRollbackToBlockOps{}.RewindWalletSyncStateHeights( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - err = pgRollbackToBlockOps{}.deleteBlocksAtOrAboveHeight( + err = pgRollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") @@ -319,14 +319,14 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { leaseOps := &pgLeaseOutputOps{} - _, err = leaseOps.acquire(t.Context(), LeaseOutputParams{ + _, err = leaseOps.Acquire(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = leaseOps.hasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.HasUtxo(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index 357b4e7417..13be5a588f 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -39,7 +39,7 @@ type rowDBTX struct { // ExecContext implements the sqlc DBTX interface. func (r rowDBTX) ExecContext(context.Context, string, - ...interface{}) (sql.Result, error) { + ...any) (sql.Result, error) { if r.execErr != nil { return nil, r.execErr @@ -55,14 +55,14 @@ func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { // QueryContext implements the sqlc DBTX interface. func (r rowDBTX) QueryContext(context.Context, string, - ...interface{}) (*sql.Rows, error) { + ...any) (*sql.Rows, error) { return nil, r.queryErr } // QueryRowContext implements the sqlc DBTX interface. func (r rowDBTX) QueryRowContext(context.Context, string, - ...interface{}) *sql.Row { + ...any) *sql.Row { if r.row != nil { return r.row @@ -73,7 +73,7 @@ func (r rowDBTX) QueryRowContext(context.Context, string, // newSQLiteRow creates a query row backed by an in-memory sqlite database so // sqlc scan paths can fail without standing up a real store. -func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { +func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { t.Helper() db, err := sql.Open("sqlite", ":memory:") @@ -95,12 +95,12 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { loadOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - _, err := loadOps.loadExisting(ctx, req) + _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -120,10 +120,10 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.confirmExisting(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, - txHash: chainhash.Hash{9}, - }, createTxExistingTarget{}) + err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, CreateTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) conflictOps := &pgCreateTxOps{ @@ -134,7 +134,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - _, _, err = conflictOps.listConflictTxns(ctx, req) + _, _, err = conflictOps.ListConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -149,12 +149,12 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { loadOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - _, err := loadOps.loadExisting(ctx, req) + _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -174,21 +174,21 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.confirmExisting(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, - txHash: chainhash.Hash{9}, - }, createTxExistingTarget{}) + err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, CreateTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) prepareOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - err = prepareOps.prepareBlock(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, + err = prepareOps.PrepareBlock(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, }) require.ErrorContains(t, err, "get block by height") @@ -200,26 +200,26 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - _, _, err = conflictOps.listConflictTxns(ctx, req) + _, _, err = conflictOps.ListConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } // TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite -// release-helper query-row error wrappers. +// Release-helper query-row error wrappers. func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} - _, err := ops.lookupUtxoID(context.Background(), ReleaseOutputParams{ + _, err := ops.LookupUtxoID(context.Background(), ReleaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, }) require.ErrorContains(t, err, "lookup utxo row") - _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) + _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) require.ErrorContains(t, err, "lookup active lease row") } @@ -232,7 +232,7 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &pgUpdateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), @@ -241,15 +241,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { } labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} - _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ Status: TxStatusPublished, }) require.ErrorIs(t, err, ErrTxNotFound) - err = labelOps.updateLabel(ctx, 1, txHash, "note") + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } @@ -262,7 +262,7 @@ func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &sqliteUpdateTxOps{ qtx: sqlcsqlite.New(rowDBTX{rows: 0}), @@ -271,14 +271,14 @@ func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { } labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} - _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ Status: TxStatusPublished, }) require.ErrorIs(t, err, ErrTxNotFound) - err = labelOps.updateLabel(ctx, 1, txHash, "note") + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 2a93607994..0378ff893f 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -69,9 +69,9 @@ func deserializeMsgTx(rawTx []byte) (*wire.MsgTx, error) { return &tx, nil } -// parseTxStatus converts a stored numeric status code into the strongly typed +// ParseTxStatus converts a stored numeric status code into the strongly typed // TxStatus enum used by the public db API. -func parseTxStatus(status int64) (TxStatus, error) { +func ParseTxStatus(status int64) (TxStatus, error) { txStatus, err := int64ToUint8(status) if err != nil { return TxStatus(0), fmt.Errorf("status %d: %w", status, @@ -93,9 +93,9 @@ func parseTxStatus(status int64) (TxStatus, error) { } } -// buildTxInfo converts normalized transaction fields into the public TxInfo +// BuildTxInfo converts normalized transaction fields into the public TxInfo // shape returned by the db interfaces. -func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, +func BuildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, status int64, label string) (*TxInfo, error) { txHash, err := chainhash.NewHash(hash) @@ -103,7 +103,7 @@ func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, return nil, fmt.Errorf("tx hash: %w", err) } - txStatus, err := parseTxStatus(status) + txStatus, err := ParseTxStatus(status) if err != nil { return nil, err } @@ -167,7 +167,7 @@ func validateCreateTxParams(params CreateTxParams) error { func validateCreateTxStatus(status TxStatus, hasBlock bool, isCoinbase bool) error { - _, err := parseTxStatus(int64(status)) + _, err := ParseTxStatus(int64(status)) if err != nil { return fmt.Errorf("%w: status %d is not supported: %w", ErrInvalidParam, status, ErrInvalidStatus) @@ -177,7 +177,7 @@ func validateCreateTxStatus(status TxStatus, hasBlock bool, // coinbase transaction. CreateTx records the initial observed facts, so it // never inserts orphaned history directly. if status == TxStatusOrphaned { - return fmt.Errorf("%w: CreateTx cannot insert orphaned txns: %w", + return fmt.Errorf("%w: CreateTx cannot Insert orphaned txns: %w", ErrInvalidParam, ErrInvalidStatus) } @@ -212,63 +212,71 @@ func validateCreateTxStatus(status TxStatus, hasBlock bool, return nil } -// createTxRequest captures the backend-independent CreateTx inputs after the +// CreateTxRequest captures the backend-independent CreateTx inputs after the // shared validation and normalization step has already succeeded. -type createTxRequest struct { - // params keeps the original public request available for backend helpers +type CreateTxRequest struct { + // Params keeps the original public request available for backend helpers // that still need the caller-supplied CreateTx metadata. - params CreateTxParams + Params CreateTxParams - // rawTx stores the serialized transaction bytes once so both backends reuse + // RawTx stores the serialized transaction bytes once so both backends reuse // the same payload throughout the write. - rawTx []byte + RawTx []byte - // txHash avoids recomputing the transaction hash across the shared flow and + // TxHash avoids recomputing the transaction hash across the shared flow and // backend adapters. - txHash chainhash.Hash + TxHash chainhash.Hash - // received is normalized to UTC before any backend insert logic runs. - received time.Time + // Received is normalized to UTC before any backend insert logic runs. + Received time.Time - // isCoinbase caches the consensus coinbase check for backend insert params. - isCoinbase bool + // IsCoinbase caches the consensus coinbase check for backend insert params. + IsCoinbase bool } -// newCreateTxRequest performs the backend-independent CreateTx preparation +// NewCreateTxRequest performs the backend-independent CreateTx preparation // shared by both SQL stores before they open a write transaction. -func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { +func NewCreateTxRequest(params CreateTxParams) (CreateTxRequest, error) { rawTx, err := serializeMsgTx(params.Tx) if err != nil { - return createTxRequest{}, err + return CreateTxRequest{}, err } err = validateCreateTxParams(params) if err != nil { - return createTxRequest{}, err + return CreateTxRequest{}, err } - return createTxRequest{ - params: params, - rawTx: rawTx, - txHash: params.Tx.TxHash(), - received: params.Received.UTC(), - isCoinbase: blockchain.IsCoinBaseTx(params.Tx), + return CreateTxRequest{ + Params: params, + RawTx: rawTx, + TxHash: params.Tx.TxHash(), + Received: params.Received.UTC(), + IsCoinbase: blockchain.IsCoinBaseTx(params.Tx), }, nil } -// createTxExistingTarget is the normalized metadata the shared CreateTx flow +// CreateTxExistingTarget is the normalized metadata the shared CreateTx flow // needs when the wallet already stores the requested tx hash. -type createTxExistingTarget struct { - id int64 - status TxStatus - hasBlock bool - isCoinbase bool +type CreateTxExistingTarget struct { + // ID is the backend row ID. + ID int64 + + // Status is the wallet-relative transaction status. + Status TxStatus + + // HasBlock reports whether the row already has confirming block metadata. + HasBlock bool + + // IsCoinbase reports whether the row records coinbase history. + IsCoinbase bool } -var errCreateTxExistingNotFound = errors.New("create tx existing target not " + +// ErrCreateTxExistingNotFound reports that CreateTx found no existing row. +var ErrCreateTxExistingNotFound = errors.New("create tx existing target not " + "found") -// createTxOps is the small semantic adapter CreateTx needs from one SQL +// CreateTxOps is the small semantic adapter CreateTx needs from one SQL // backend. // // The shared CreateTx algorithm is intentionally linear: @@ -277,101 +285,101 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // inserting a duplicate // - validate and cache any confirming block metadata before later writes use // it -// - insert the base transaction row exactly once when no existing row can be +// - Insert the base transaction row exactly once when no existing row can be // reused // - when the incoming tx is confirmed, discover any direct conflict roots // before later writes claim shared inputs or rewrite that branch // - if direct conflicts were found, rewrite those roots to replaced state, // fail their descendants, and record replacement history before the new // row claims their wallet-owned inputs -// - insert every wallet-owned credited output as a UTXO +// - Insert every wallet-owned credited output as a UTXO // - attach any wallet-owned spent inputs to that final transaction row // // Each backend implements those steps with its own sqlc-generated query types -// while createTxWithOps keeps the high-level sequencing in one place. +// while CreateTxWithOps keeps the high-level sequencing in one place. // That sequencing matters because confirmation, conflict invalidation, credit // creation, and spent-input claims must either all observe the same tx row or // all roll back together. -type createTxOps interface { - invalidateUnminedTxOps +type CreateTxOps interface { + InvalidateUnminedTxOps - // loadExisting loads any existing wallet-scoped transaction row for the + // LoadExisting loads any existing wallet-scoped transaction row for the // same hash. - loadExisting(ctx context.Context, req createTxRequest) ( - *createTxExistingTarget, error) + LoadExisting(ctx context.Context, req CreateTxRequest) ( + *CreateTxExistingTarget, error) - // confirmExisting reuses one existing row when CreateTx learns about the + // ConfirmExisting reuses one existing row when CreateTx learns about the // same transaction with confirming block context later. - confirmExisting(ctx context.Context, req createTxRequest, - existing createTxExistingTarget) error + ConfirmExisting(ctx context.Context, req CreateTxRequest, + existing CreateTxExistingTarget) error - // prepareBlock validates and caches any optional confirming block metadata - // the later insert step needs. - prepareBlock(ctx context.Context, req createTxRequest) error + // PrepareBlock validates and caches any optional confirming block metadata + // the later Insert step needs. + PrepareBlock(ctx context.Context, req CreateTxRequest) error - // listConflictTxns returns the direct wallet-owned conflict tx IDs plus the + // ListConflictTxns returns the direct wallet-owned conflict tx IDs plus the // corresponding hashes used for descendant discovery. - listConflictTxns(ctx context.Context, req createTxRequest) ([]int64, + ListConflictTxns(ctx context.Context, req CreateTxRequest) ([]int64, []chainhash.Hash, error) - // markTxnsReplaced batch-marks the provided direct conflict roots + // MarkTxnsReplaced batch-marks the provided direct conflict roots // as replaced. - markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error + MarkTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error - // insertReplacementEdges records replacement-history edges from each direct + // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. - insertReplacementEdges(ctx context.Context, walletID int64, + InsertReplacementEdges(ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error - // insert writes the base transaction row and returns its new primary key. - insert(ctx context.Context, req createTxRequest) (int64, error) + // Insert writes the base transaction row and returns its new primary key. + Insert(ctx context.Context, req CreateTxRequest) (int64, error) - // insertCredits records every wallet-owned output that the caller + // InsertCredits records every wallet-owned output that the caller // marked as a credit for this transaction. - insertCredits(ctx context.Context, req createTxRequest, txID int64) error + InsertCredits(ctx context.Context, req CreateTxRequest, txID int64) error - // markInputsSpent attaches wallet-owned parent outpoints to this + // MarkInputsSpent attaches wallet-owned parent outpoints to this // transaction row and rejects conflicts or invalid wallet parents. - markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error + MarkInputsSpent(ctx context.Context, req CreateTxRequest, txID int64) error } // checkReuseCreateTx reports whether CreateTx should reuse an existing wallet- // scoped row instead of inserting a new one. -func checkReuseCreateTx(req createTxRequest, - existing createTxExistingTarget) bool { +func checkReuseCreateTx(req CreateTxRequest, + existing CreateTxExistingTarget) bool { // Only a newly confirmed observation can reuse an existing row. // Plain unmined inserts still create fresh unmined history // instead of rewriting one existing record in place. - if req.params.Block == nil { + if req.Params.Block == nil { return false } // Reuse is only for the mined published state that records // the wallet's final view of the tx once a block anchors it. - if req.params.Status != TxStatusPublished { + if req.Params.Status != TxStatusPublished { return false } // A row that already has a confirming block is already in its final mined // form, so CreateTx must reject the duplicate instead of mutating it again. - if existing.hasBlock { + if existing.HasBlock { return false } // Coinbase rows only reuse the orphaned state. That path restores the same // coinbase hash after rollback disconnected its previous confirming block. - if existing.isCoinbase { + if existing.IsCoinbase { // Both sides must still be coinbase history, and the existing // row must be the rollback-created orphan that is waiting for a // confirming block again. - return req.isCoinbase && existing.status == TxStatusOrphaned + return req.IsCoinbase && existing.Status == TxStatusOrphaned } // Non-coinbase rows only reuse the current unmined states. // Once a row is invalidated, UpdateTx/DeleteTx no longer // treat it as a live unmined target. - if !isUnminedStatus(existing.status) { + if !IsUnminedStatus(existing.Status) { return false } @@ -380,15 +388,15 @@ func checkReuseCreateTx(req createTxRequest, // loadCreateTxExisting resolves any wallet-scoped row already stored for the // requested tx hash and reports whether one was found. -func loadCreateTxExisting(ctx context.Context, req createTxRequest, - ops createTxOps) (*createTxExistingTarget, bool, error) { +func loadCreateTxExisting(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) (*CreateTxExistingTarget, bool, error) { - existing, err := ops.loadExisting(ctx, req) - if err != nil && !errors.Is(err, errCreateTxExistingNotFound) { + existing, err := ops.LoadExisting(ctx, req) + if err != nil && !errors.Is(err, ErrCreateTxExistingNotFound) { return nil, false, fmt.Errorf("load create tx target: %w", err) } - if errors.Is(err, errCreateTxExistingNotFound) { + if errors.Is(err, ErrCreateTxExistingNotFound) { return nil, false, nil } @@ -405,14 +413,14 @@ func loadCreateTxExisting(ctx context.Context, req createTxRequest, // NOTE: rootHashes is expected to be a set with unique tx hashes. func collectConflictDescendants(ctx context.Context, walletID int64, rootHashes []chainhash.Hash, rootIDs []int64, - ops createTxOps) ([]int64, error) { + ops CreateTxOps) ([]int64, error) { - candidates, err := ops.listUnminedTxRecords(ctx, walletID) + candidates, err := ops.ListUnminedTxRecords(ctx, walletID) if err != nil { return nil, fmt.Errorf("list create tx conflict candidates: %w", err) } - descendantIDs := collectDescendantTxIDs(rootHashes, rootIDs, candidates) + descendantIDs := CollectDescendantTxIDs(rootHashes, rootIDs, candidates) return descendantIDs, nil } @@ -420,21 +428,21 @@ func collectConflictDescendants(ctx context.Context, walletID int64, // handleRootTxns clears the direct root spends, marks those rows replaced, and // records replacement edges to the winning confirmed tx. func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, - replacementTxID int64, ops createTxOps) error { + replacementTxID int64, ops CreateTxOps) error { for _, rootID := range rootIDs { - err := ops.clearSpentUtxos(ctx, walletID, rootID) + err := ops.ClearSpentUtxos(ctx, walletID, rootID) if err != nil { return fmt.Errorf("clear replaced root spent utxos: %w", err) } } - err := ops.markTxnsReplaced(ctx, walletID, rootIDs) + err := ops.MarkTxnsReplaced(ctx, walletID, rootIDs) if err != nil { return fmt.Errorf("mark direct conflicts replaced: %w", err) } - err = ops.insertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) + err = ops.InsertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) if err != nil { return fmt.Errorf("record conflict replacement edges: %w", err) } @@ -445,20 +453,20 @@ func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, // handleTxDescendants clears the discovered descendant spends and then marks // that dependent branch failed. func handleTxDescendants(ctx context.Context, walletID int64, - descendantIDs []int64, ops createTxOps) error { + descendantIDs []int64, ops CreateTxOps) error { if len(descendantIDs) == 0 { return nil } for _, descendantID := range descendantIDs { - err := ops.clearSpentUtxos(ctx, walletID, descendantID) + err := ops.ClearSpentUtxos(ctx, walletID, descendantID) if err != nil { return fmt.Errorf("clear failed descendant spent utxos: %w", err) } } - err := ops.markTxnsFailed(ctx, walletID, descendantIDs) + err := ops.MarkTxnsFailed(ctx, walletID, descendantIDs) if err != nil { return fmt.Errorf("mark conflict descendants failed: %w", err) } @@ -480,17 +488,17 @@ func handleTxDescendants(ctx context.Context, walletID int64, // That sequencing preserves replacement history for the direct conflicts while // still invalidating the dependent branch atomically inside one SQL // transaction. -func handleTxConflicts(ctx context.Context, req createTxRequest, - replacementTxID int64, ops createTxOps) error { +func handleTxConflicts(ctx context.Context, req CreateTxRequest, + replacementTxID int64, ops CreateTxOps) error { // Only confirmed inserts can replace an active unmined branch. - if req.params.Block == nil { + if req.Params.Block == nil { return nil } // Load the direct roots first so every later step works from the currently // visible spend edges on the shared parent inputs. - rootIDs, rootHashes, err := ops.listConflictTxns(ctx, req) + rootIDs, rootHashes, err := ops.ListConflictTxns(ctx, req) if err != nil { return fmt.Errorf("list conflict txns: %w", err) } @@ -500,7 +508,7 @@ func handleTxConflicts(ctx context.Context, req createTxRequest, return nil } - walletID := int64(req.params.WalletID) + walletID := int64(req.Params.WalletID) // Discover descendants before any mutation starts. // Later rewrites can otherwise hide part of the displaced @@ -527,28 +535,28 @@ func handleTxConflicts(ctx context.Context, req createTxRequest, return nil } -// insertCreateTx completes the fresh-insert CreateTx path. +// insertCreateTx completes the fresh-Insert CreateTx path. // // The order is important: -// - insert first so the new winner row has a stable tx ID +// - Insert first so the new winner row has a stable tx ID // - reconcile conflicts next while the displaced unmined branch is still // discoverable through the current spend edges // - create wallet-owned credits after replacement handling // - claim wallet-owned parent inputs last, because that rewires the shared // spend edges to the new winner row -func insertCreateTx(ctx context.Context, req createTxRequest, - ops createTxOps) error { +func insertCreateTx(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) error { // Insert the winner row first so every later write can point at its stable // primary key. In particular, replacement-history edges need the new tx ID. - txID, err := ops.insert(ctx, req) + txID, err := ops.Insert(ctx, req) if err != nil { return fmt.Errorf("insert tx: %w", err) } // Reconcile any conflicting unmined branch before this tx claims the shared // parent inputs. Conflict discovery looks at the current spend edges on - // those parents; once markInputsSpent rewires them to this new row, the old + // those parents; once MarkInputsSpent rewires them to this new row, the old // branch is no longer discoverable as the direct conflict root. err = handleTxConflicts(ctx, req, txID, ops) if err != nil { @@ -559,7 +567,7 @@ func insertCreateTx(ctx context.Context, req createTxRequest, // not interfere with conflict discovery. Keep them after replacement // handling so the branch rewrite stays grouped with the shared-input // reconciliation. - err = ops.insertCredits(ctx, req, txID) + err = ops.InsertCredits(ctx, req, txID) if err != nil { return fmt.Errorf("create tx credits: %w", err) } @@ -567,7 +575,7 @@ func insertCreateTx(ctx context.Context, req createTxRequest, // Claim wallet-owned parent inputs last. This is the write that makes the // new tx the recorded spender of the shared parents, so doing it earlier // would hide the displaced unmined branch from the replacement walk. - err = ops.markInputsSpent(ctx, req, txID) + err = ops.MarkInputsSpent(ctx, req, txID) if err != nil { return fmt.Errorf("create tx spends: %w", err) } @@ -575,15 +583,15 @@ func insertCreateTx(ctx context.Context, req createTxRequest, return nil } -// createTxWithOps runs the backend-independent CreateTx orchestration once the +// CreateTxWithOps runs the backend-independent CreateTx orchestration once the // caller has opened a backend-specific SQL transaction. // -// The helper can either confirm an existing unmined row or insert a new row. +// The helper can either confirm an existing unmined row or Insert a new row. // For confirmed inserts it also reconciles any current direct conflict branch // before the new row claims wallet-owned inputs. The helper owns that ordering // so the backends only need to supply query wiring and type conversion. -func createTxWithOps(ctx context.Context, req createTxRequest, - ops createTxOps) error { +func CreateTxWithOps(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) error { existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) if err != nil { @@ -592,10 +600,10 @@ func createTxWithOps(ctx context.Context, req createTxRequest, if foundExisting { if !checkReuseCreateTx(req, *existing) { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxAlreadyExists) } - err = ops.confirmExisting(ctx, req, *existing) + err = ops.ConfirmExisting(ctx, req, *existing) if err != nil { return fmt.Errorf("confirm existing tx: %w", err) } @@ -603,7 +611,7 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } - err = ops.prepareBlock(ctx, req) + err = ops.PrepareBlock(ctx, req) if err != nil { return fmt.Errorf("prepare create block assignment: %w", err) } @@ -630,7 +638,7 @@ func validateUpdateTxParams(params UpdateTxParams, isCoinbase bool) error { // validateUpdateTxState checks the block/status combinations UpdateTx may store // on an existing row. func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { - _, err := parseTxStatus(int64(state.Status)) + _, err := ParseTxStatus(int64(state.Status)) if err != nil { return fmt.Errorf("%w: status %d is not supported: %w", ErrInvalidParam, state.Status, ErrInvalidStatus) @@ -665,7 +673,7 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { return nil } -// updateTxOps is the minimal backend adapter the shared UpdateTx workflow +// UpdateTxOps is the minimal backend adapter the shared UpdateTx workflow // needs. // // The shared UpdateTx algorithm is intentionally ordered: @@ -680,36 +688,36 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { // shared helper owns the mutation ordering and the invariants that keep // block/status patches from accidentally turning into graph-level // reconciliation. -type updateTxOps interface { - // loadIsCoinbase returns whether the existing row is coinbase history +type UpdateTxOps interface { + // LoadIsCoinbase returns whether the existing row is coinbase history // so the shared validation can enforce orphaning rules correctly. - loadIsCoinbase(ctx context.Context, walletID uint32, + LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) - // prepareState validates and caches any backend-specific block/status + // PrepareState validates and caches any backend-specific block/status // params needed for the later row update. - prepareState(ctx context.Context, state UpdateTxState) error + PrepareState(ctx context.Context, state UpdateTxState) error - // updateLabel applies one user-visible label patch. - updateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, + // UpdateLabel applies one user-visible label patch. + UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error - // updateState applies one block/status patch after prepareState succeeds. - updateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, + // UpdateState applies one block/status patch after PrepareState succeeds. + UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, state UpdateTxState) error } -// updateTxWithOps runs the shared UpdateTx patch workflow inside one backend- +// UpdateTxWithOps runs the shared UpdateTx patch workflow inside one backend- // specific SQL transaction. // // The helper validates the existing row first, prepares any requested state // patch next, and then applies the label patch and state patch in that order. // That keeps block validation and row mutation inside one transaction while // still allowing callers to update either field independently. -func updateTxWithOps(ctx context.Context, params UpdateTxParams, - ops updateTxOps) error { +func UpdateTxWithOps(ctx context.Context, params UpdateTxParams, + ops UpdateTxOps) error { - isCoinbase, err := ops.loadIsCoinbase(ctx, params.WalletID, params.Txid) + isCoinbase, err := ops.LoadIsCoinbase(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load update tx target: %w", err) } @@ -720,21 +728,21 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, } if params.State != nil { - err = ops.prepareState(ctx, *params.State) + err = ops.PrepareState(ctx, *params.State) if err != nil { return fmt.Errorf("prepare tx state update: %w", err) } } if params.Label != nil { - err = ops.updateLabel(ctx, params.WalletID, params.Txid, *params.Label) + err = ops.UpdateLabel(ctx, params.WalletID, params.Txid, *params.Label) if err != nil { return fmt.Errorf("update tx label: %w", err) } } if params.State != nil { - err = ops.updateState(ctx, params.WalletID, params.Txid, *params.State) + err = ops.UpdateState(ctx, params.WalletID, params.Txid, *params.State) if err != nil { return fmt.Errorf("update tx state: %w", err) } @@ -743,7 +751,7 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, return nil } -// deleteTxOps is the minimal backend adapter the shared DeleteTx workflow +// DeleteTxOps is the minimal backend adapter the shared DeleteTx workflow // needs. // // The shared delete sequence is: @@ -756,61 +764,61 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, // DeleteTx is the ordinary leaf-cleanup path, not the invalidation path. The // shared helper therefore proves the leaf invariant first and only then unwinds // the wallet-owned state that points at the target row. -type deleteTxOps interface { - // loadDeleteTarget returns the row ID of the unmined transaction +type DeleteTxOps interface { + // LoadDeleteTarget returns the row ID of the unmined transaction // DeleteTx is allowed to remove. - loadDeleteTarget(ctx context.Context, walletID uint32, + LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) - // ensureLeaf rejects DeleteTx when the target still has direct + // EnsureLeaf rejects DeleteTx when the target still has direct // unmined child spenders. - ensureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, + EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error - // clearSpentUtxos restores any wallet-owned parent outputs the + // ClearSpentUtxos restores any wallet-owned parent outputs the // transaction had marked spent. - clearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error + ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error - // deleteCreatedUtxos removes any wallet-owned outputs created by the + // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. - deleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error + DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error - // deleteUnminedTransaction removes the target row after its dependent + // DeleteUnminedTransaction removes the target row after its dependent // wallet state has been cleaned up. - deleteUnminedTransaction(ctx context.Context, walletID uint32, + DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) } -// deleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific +// DeleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific // SQL transaction. // // The helper restores wallet-owned parent state before deleting created wallet // outputs and only removes the transaction row last, so a failed delete cannot // leave partial wallet bookkeeping behind. -func deleteTxWithOps(ctx context.Context, params DeleteTxParams, - ops deleteTxOps) error { +func DeleteTxWithOps(ctx context.Context, params DeleteTxParams, + ops DeleteTxOps) error { - txID, err := ops.loadDeleteTarget(ctx, params.WalletID, params.Txid) + txID, err := ops.LoadDeleteTarget(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load delete tx target: %w", err) } - err = ops.ensureLeaf(ctx, params.WalletID, params.Txid, txID) + err = ops.EnsureLeaf(ctx, params.WalletID, params.Txid, txID) if err != nil { return fmt.Errorf("check delete tx leaf: %w", err) } - err = ops.clearSpentUtxos(ctx, params.WalletID, txID) + err = ops.ClearSpentUtxos(ctx, params.WalletID, txID) if err != nil { return fmt.Errorf("clear spent utxos: %w", err) } - err = ops.deleteCreatedUtxos(ctx, params.WalletID, txID) + err = ops.DeleteCreatedUtxos(ctx, params.WalletID, txID) if err != nil { return fmt.Errorf("delete created utxos: %w", err) } - rows, err := ops.deleteUnminedTransaction(ctx, params.WalletID, params.Txid) + rows, err := ops.DeleteUnminedTransaction(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("delete unmined tx: %w", err) } @@ -822,19 +830,24 @@ func deleteTxWithOps(ctx context.Context, params DeleteTxParams, return nil } -// unminedTxRecord is the decoded view of one unmined transaction row used by +// UnminedTxRecord is the decoded view of one unmined transaction row used by // shared descendant checks. -type unminedTxRecord struct { - id int64 - hash chainhash.Hash - tx *wire.MsgTx +type UnminedTxRecord struct { + // ID is the backend row ID. + ID int64 + + // Hash is the transaction hash used for graph traversal. + Hash chainhash.Hash + + // Tx is the decoded transaction payload. + Tx *wire.MsgTx } -// extractUnminedTxFn projects one backend-specific unmined transaction row into +// ExtractUnminedTxFn projects one backend-specific unmined transaction row into // the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. -type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) +type ExtractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) -// rollbackToBlockOps adapts one SQL backend to the full RollbackToBlock +// RollbackToBlockOps adapts one SQL backend to the full RollbackToBlock // sequence, including sync-state rewinds, block deletion, and descendant // invalidation. // @@ -848,65 +861,65 @@ type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) // // The adapter methods map directly to those stages so the shared helper can own // the rollback sequencing while the backends keep only query details. -type rollbackToBlockOps interface { - // listRollbackRootHashes returns the coinbase roots disconnected by the +type RollbackToBlockOps interface { + // ListRollbackRootHashes returns the coinbase roots disconnected by the // rollback, grouped by wallet for the later descendant walk. - listRollbackRootHashes(ctx context.Context, + ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) - // rewindWalletSyncStateHeights clamps wallet sync-state references + // RewindWalletSyncStateHeights clamps wallet sync-state references // below the rollback boundary before block rows are removed. - rewindWalletSyncStateHeights(ctx context.Context, height uint32) error + RewindWalletSyncStateHeights(ctx context.Context, height uint32) error - // deleteBlocksAtOrAboveHeight removes the shared block rows at or above the + // DeleteBlocksAtOrAboveHeight removes the shared block rows at or above the // rollback boundary after sync-state references have been rewound. - deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + DeleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error - // markTxRootsOrphaned rewrites the disconnected coinbase roots to the + // MarkTxRootsOrphaned rewrites the disconnected coinbase roots to the // orphaned state after their confirming blocks are deleted. - markTxRootsOrphaned(ctx context.Context, walletID uint32, + MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error - // listUnminedTxRecords loads the wallet's current unmined transaction + // ListUnminedTxRecords loads the wallet's current unmined transaction // rows in the normalized shape the descendant walk expects. - listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) + ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) - // clearDescendantSpends removes any wallet-owned spend edges claimed by one + // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant before its status is rewritten. - clearDescendantSpends(ctx context.Context, walletID int64, + ClearDescendantSpends(ctx context.Context, walletID int64, descendantID int64) error - // markDescendantsFailed batch-marks the discovered descendants as + // MarkDescendantsFailed batch-marks the discovered descendants as // failed once every dependent spend edge has been cleared. - markDescendantsFailed(ctx context.Context, walletID int64, + MarkDescendantsFailed(ctx context.Context, walletID int64, descendantIDs []int64) error } // newUnminedTxRecord decodes one normalized unmined transaction row into the // shared dependency-walk shape. func newUnminedTxRecord(id int64, hash []byte, - rawTx []byte) (unminedTxRecord, error) { + rawTx []byte) (UnminedTxRecord, error) { txHash, err := chainhash.NewHash(hash) if err != nil { - return unminedTxRecord{}, fmt.Errorf("tx hash: %w", err) + return UnminedTxRecord{}, fmt.Errorf("tx hash: %w", err) } tx, err := deserializeMsgTx(rawTx) if err != nil { - return unminedTxRecord{}, err + return UnminedTxRecord{}, err } - return unminedTxRecord{id: id, hash: *txHash, tx: tx}, nil + return UnminedTxRecord{ID: id, Hash: *txHash, Tx: tx}, nil } -// buildUnminedTxRecords decodes backend-specific unmined transaction rows into +// BuildUnminedTxRecords decodes backend-specific unmined transaction rows into // the shared dependency-walk shape. -func buildUnminedTxRecords[T any](rows []T, - extract extractUnminedTxFn[T]) ([]unminedTxRecord, error) { +func BuildUnminedTxRecords[T any](rows []T, + extract ExtractUnminedTxFn[T]) ([]UnminedTxRecord, error) { - records := make([]unminedTxRecord, 0, len(rows)) + records := make([]UnminedTxRecord, 0, len(rows)) for _, row := range rows { id, hash, rawTx := extract(row) @@ -921,10 +934,10 @@ func buildUnminedTxRecords[T any](rows []T, return records, nil } -// collectDirectChildTxIDs returns the IDs of unmined transactions that directly +// CollectDirectChildTxIDs returns the IDs of unmined transactions that directly // spend any output created by the provided parent hash. -func collectDirectChildTxIDs(parentHash chainhash.Hash, - candidates []unminedTxRecord) []int64 { +func CollectDirectChildTxIDs(parentHash chainhash.Hash, + candidates []UnminedTxRecord) []int64 { parentHashes := map[chainhash.Hash]struct{}{ parentHash: {}, @@ -932,19 +945,19 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, childIDs := make([]int64, 0, len(candidates)) for _, candidate := range candidates { - if txSpendsAnyParent(candidate.tx, parentHashes) { - childIDs = append(childIDs, candidate.id) + if txSpendsAnyParent(candidate.Tx, parentHashes) { + childIDs = append(childIDs, candidate.ID) } } return childIDs } -// collectDescendantTxIDs returns every discovered descendant in the original +// CollectDescendantTxIDs returns every discovered descendant in the original // candidate order. Any ID also listed in rootIDs is excluded so direct roots // can keep their own state transition instead of being treated as descendants. -func collectDescendantTxIDs(rootHashes []chainhash.Hash, - rootIDs []int64, candidates []unminedTxRecord) []int64 { +func CollectDescendantTxIDs(rootHashes []chainhash.Hash, + rootIDs []int64, candidates []UnminedTxRecord) []int64 { invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) for _, hash := range rootHashes { @@ -960,16 +973,16 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, changed = false for _, candidate := range candidates { - if _, ok := invalidIDs[candidate.id]; ok { + if _, ok := invalidIDs[candidate.ID]; ok { continue } - if !txSpendsAnyParent(candidate.tx, invalidHashes) { + if !txSpendsAnyParent(candidate.Tx, invalidHashes) { continue } - invalidIDs[candidate.id] = struct{}{} - invalidHashes[candidate.hash] = struct{}{} + invalidIDs[candidate.ID] = struct{}{} + invalidHashes[candidate.Hash] = struct{}{} changed = true } } @@ -982,8 +995,8 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, orderedIDs := make([]int64, 0, len(invalidIDs)) for _, candidate := range candidates { - if _, ok := invalidIDs[candidate.id]; ok { - orderedIDs = append(orderedIDs, candidate.id) + if _, ok := invalidIDs[candidate.ID]; ok { + orderedIDs = append(orderedIDs, candidate.ID) } } @@ -994,31 +1007,31 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, // unmined descendant discovered from the provided wallet-scoped rollback roots. func invalidateRollbackDescendants(ctx context.Context, rootHashesByWallet map[uint32][]chainhash.Hash, - ops rollbackToBlockOps) error { + ops RollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { walletID64 := int64(walletID) - candidates, err := ops.listUnminedTxRecords(ctx, walletID64) + candidates, err := ops.ListUnminedTxRecords(ctx, walletID64) if err != nil { return fmt.Errorf("list unmined rollback descendants for "+ "wallet %d: %w", walletID, err) } - descendantIDs := collectDescendantTxIDs(rootHashes, nil, candidates) + descendantIDs := CollectDescendantTxIDs(rootHashes, nil, candidates) if len(descendantIDs) == 0 { continue } for _, descendantID := range descendantIDs { - err = ops.clearDescendantSpends(ctx, walletID64, descendantID) + err = ops.ClearDescendantSpends(ctx, walletID64, descendantID) if err != nil { return fmt.Errorf("clear rollback descendant spends for "+ "wallet %d: %w", walletID, err) } } - err = ops.markDescendantsFailed(ctx, walletID64, descendantIDs) + err = ops.MarkDescendantsFailed(ctx, walletID64, descendantIDs) if err != nil { return fmt.Errorf("mark rollback descendants failed for "+ "wallet %d: %w", walletID, err) @@ -1028,14 +1041,14 @@ func invalidateRollbackDescendants(ctx context.Context, return nil } -// markTxRootsOrphaned rewrites every disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites every disconnected coinbase root to the // orphaned state before descendant invalidation completes. -func markTxRootsOrphaned(ctx context.Context, +func MarkTxRootsOrphaned(ctx context.Context, rootHashesByWallet map[uint32][]chainhash.Hash, - ops rollbackToBlockOps) error { + ops RollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { - err := ops.markTxRootsOrphaned(ctx, walletID, rootHashes) + err := ops.MarkTxRootsOrphaned(ctx, walletID, rootHashes) if err != nil { return fmt.Errorf("mark rollback coinbase roots orphaned for "+ "wallet %d: %w", walletID, err) @@ -1045,31 +1058,31 @@ func markTxRootsOrphaned(ctx context.Context, return nil } -// rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one +// RollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one // backend-specific SQL transaction. // // The helper rewinds sync-state heights before deleting blocks, then clears and // fails any now-invalid unmined descendants rooted in disconnected coinbase // history so rollback cannot leave dangling references behind. -func rollbackToBlockWithOps(ctx context.Context, height uint32, - ops rollbackToBlockOps) error { +func RollbackToBlockWithOps(ctx context.Context, height uint32, + ops RollbackToBlockOps) error { - rootHashesByWallet, err := ops.listRollbackRootHashes(ctx, height) + rootHashesByWallet, err := ops.ListRollbackRootHashes(ctx, height) if err != nil { return fmt.Errorf("list rollback coinbase roots: %w", err) } - err = ops.rewindWalletSyncStateHeights(ctx, height) + err = ops.RewindWalletSyncStateHeights(ctx, height) if err != nil { return fmt.Errorf("rewind wallet sync state heights: %w", err) } - err = ops.deleteBlocksAtOrAboveHeight(ctx, height) + err = ops.DeleteBlocksAtOrAboveHeight(ctx, height) if err != nil { return fmt.Errorf("delete blocks at or above height: %w", err) } - err = markTxRootsOrphaned(ctx, rootHashesByWallet, ops) + err = MarkTxRootsOrphaned(ctx, rootHashesByWallet, ops) if err != nil { return err } @@ -1096,9 +1109,9 @@ func txSpendsAnyParent(tx *wire.MsgTx, return false } -// isUnminedStatus reports whether a status still represents an unmined +// IsUnminedStatus reports whether a status still represents an unmined // transaction that DeleteTx may erase. -func isUnminedStatus(status TxStatus) bool { +func IsUnminedStatus(status TxStatus) bool { switch status { case TxStatusPending, TxStatusPublished: return true diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 7e515c199c..58c7d9ffd4 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -73,7 +73,7 @@ func TestParseTxStatus(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := parseTxStatus(tc.status) + got, err := ParseTxStatus(tc.status) require.ErrorIs(t, err, tc.wantErr) require.Equal(t, tc.want, got) }) @@ -85,7 +85,7 @@ func TestParseTxStatus(t *testing.T) { func TestParseTxStatusNegativeValue(t *testing.T) { t.Parallel() - _, err := parseTxStatus(-1) + _, err := ParseTxStatus(-1) require.ErrorIs(t, err, ErrInvalidStatus) } @@ -110,7 +110,7 @@ func TestIsUnminedStatus(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - require.Equal(t, test.want, isUnminedStatus(test.status)) + require.Equal(t, test.want, IsUnminedStatus(test.status)) }) } } @@ -134,7 +134,7 @@ func TestBuildTxInfo(t *testing.T) { } // Act: Convert the normalized row fields into TxInfo. - info, err := buildTxInfo( + info, err := BuildTxInfo( hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), block, int64(TxStatusPublished), "note", ) @@ -149,7 +149,7 @@ func TestBuildTxInfo(t *testing.T) { require.Equal(t, block, info.Block) } -// TestBuildTxInfoInvalidHash verifies that buildTxInfo rejects malformed hash +// TestBuildTxInfoInvalidHash verifies that BuildTxInfo rejects malformed hash // bytes. func TestBuildTxInfoInvalidHash(t *testing.T) { t.Parallel() @@ -158,12 +158,12 @@ func TestBuildTxInfoInvalidHash(t *testing.T) { rawTx, err := serializeMsgTx(tx) require.NoError(t, err) - _, err = buildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, + _, err = BuildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, int64(TxStatusPending), "") require.Error(t, err) } -// TestBuildTxInfoInvalidStatus verifies that buildTxInfo rejects unknown status +// TestBuildTxInfoInvalidStatus verifies that BuildTxInfo rejects unknown status // codes. func TestBuildTxInfoInvalidStatus(t *testing.T) { t.Parallel() @@ -173,7 +173,7 @@ func TestBuildTxInfoInvalidStatus(t *testing.T) { rawTx, err := serializeMsgTx(tx) require.NoError(t, err) - _, err = buildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") + _, err = BuildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") require.ErrorIs(t, err, ErrInvalidStatus) } @@ -335,23 +335,23 @@ func TestNewCreateTxRequest(t *testing.T) { Label: "note", } - // Act: Normalize it through newCreateTxRequest. - req, err := newCreateTxRequest(params) + // Act: Normalize it through NewCreateTxRequest. + req, err := NewCreateTxRequest(params) require.NoError(t, err) wantRawTx, err := serializeMsgTx(params.Tx) require.NoError(t, err) // Assert: The prepared request caches the normalized transaction facts. - require.Equal(t, params, req.params) - require.Equal(t, params.Tx.TxHash(), req.txHash) - require.Equal(t, wantRawTx, req.rawTx) - require.Equal(t, time.UTC, req.received.Location()) - require.False(t, req.isCoinbase) + require.Equal(t, params, req.Params) + require.Equal(t, params.Tx.TxHash(), req.TxHash) + require.Equal(t, wantRawTx, req.RawTx) + require.Equal(t, time.UTC, req.Received.Location()) + require.False(t, req.IsCoinbase) } // TestCreateTxWithOpsInsert verifies that the shared CreateTx orchestration -// performs the full insert path in order for one fresh transaction row. +// performs the full Insert path in order for one fresh transaction row. func TestCreateTxWithOpsInsert(t *testing.T) { t.Parallel() @@ -359,7 +359,7 @@ func TestCreateTxWithOpsInsert(t *testing.T) { req := testCreateTxRequest(t) var ( - insertReq createTxRequest + insertReq CreateTxRequest creditsID int64 inputsID int64 ) @@ -369,43 +369,46 @@ func TestCreateTxWithOpsInsert(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() - ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + ops.On("PrepareBlock", mock.Anything, req).Return(nil).Once() - ops.On("insert", mock.Anything, req).Return(int64(11), nil).Run( + ops.On("Insert", mock.Anything, req).Return(int64(11), nil).Run( func(args mock.Arguments) { - reqArg, ok := args.Get(1).(createTxRequest) + reqArg, ok := args.Get(1).(CreateTxRequest) require.True(t, ok) + insertReq = reqArg }, ).Once() - ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Run( + ops.On("InsertCredits", mock.Anything, req, int64(11)).Return(nil).Run( func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + creditsID = txID }, ).Once() - ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( + ops.On("MarkInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + inputsID = txID }, ).Once() - // Act: Run createTxWithOps. - err := createTxWithOps(context.Background(), req, ops) + // Act: Run CreateTxWithOps. + err := CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) // Assert: The shared flow uses the inserted tx ID consistently. require.Equal(t, int64(11), creditsID) require.Equal(t, int64(11), inputsID) - require.Equal(t, req.txHash, insertReq.txHash) + require.Equal(t, req.TxHash, insertReq.TxHash) } // TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an @@ -419,10 +422,10 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - &createTxExistingTarget{id: 4}, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + &CreateTxExistingTarget{ID: 4}, nil).Once() - err := createTxWithOps(context.Background(), req, ops) + err := CreateTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) } @@ -432,7 +435,7 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { func TestCreateTxWithOpsConfirmExisting(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -442,21 +445,21 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { }) require.NoError(t, err) - existing := createTxExistingTarget{ - id: 7, - status: TxStatusPending, - hasBlock: false, + existing := CreateTxExistingTarget{ + ID: 7, + Status: TxStatusPending, + HasBlock: false, } ops := &mockCreateTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() - ops.On("confirmExisting", mock.Anything, req, existing).Return(nil).Once() + ops.On("ConfirmExisting", mock.Anything, req, existing).Return(nil).Once() - err = createTxWithOps(context.Background(), req, ops) + err = CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) } @@ -466,7 +469,7 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -483,34 +486,34 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() - ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + ops.On("PrepareBlock", mock.Anything, req).Return(nil).Once() - ops.On("insert", mock.Anything, req).Return(int64(11), nil).Once() + ops.On("Insert", mock.Anything, req).Return(int64(11), nil).Once() - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( rootIDs, rootHashes, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(5)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(5)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(5), []int64{5}, + ops.On("InsertReplacementEdges", mock.Anything, int64(5), []int64{5}, int64(11)).Return(nil).Once() - ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Once() + ops.On("InsertCredits", mock.Anything, req, int64(11)).Return(nil).Once() - ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() + ops.On("MarkInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() - err = createTxWithOps(context.Background(), req, ops) + err = CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) } @@ -544,51 +547,51 @@ func testCoinbaseMsgTx() *wire.MsgTx { return tx } -// mockCreateTxOps is a mock implementation of createTxOps. +// mockCreateTxOps is a mock implementation of CreateTxOps. type mockCreateTxOps struct { mock.Mock } -var _ createTxOps = (*mockCreateTxOps)(nil) +var _ CreateTxOps = (*mockCreateTxOps)(nil) -// loadExisting implements createTxOps. -func (m *mockCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting implements CreateTxOps. +func (m *mockCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { args := m.Called(ctx, req) if args.Get(0) == nil { return nil, args.Error(1) } - existing, ok := args.Get(0).(*createTxExistingTarget) + existing, ok := args.Get(0).(*CreateTxExistingTarget) if !ok { - return nil, mockTypeError("loadExisting result") + return nil, mockTypeError("LoadExisting result") } return existing, args.Error(1) } -// confirmExisting implements createTxOps. -func (m *mockCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, existing createTxExistingTarget) error { +// ConfirmExisting implements CreateTxOps. +func (m *mockCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, existing CreateTxExistingTarget) error { args := m.Called(ctx, req, existing) return args.Error(0) } -// prepareBlock implements createTxOps. -func (m *mockCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock implements CreateTxOps. +func (m *mockCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { args := m.Called(ctx, req) return args.Error(0) } -// listConflictTxns implements createTxOps. -func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +// ListConflictTxns implements CreateTxOps. +func (m *mockCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { args := m.Called(ctx, req) if args.Get(0) == nil { @@ -597,56 +600,56 @@ func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, txIDs, ok := args.Get(0).([]int64) if !ok { - return nil, nil, mockTypeError("listConflictTxns ids") + return nil, nil, mockTypeError("ListConflictTxns ids") } hashes, ok := args.Get(1).([]chainhash.Hash) if !ok { - return nil, nil, mockTypeError("listConflictTxns hashes") + return nil, nil, mockTypeError("ListConflictTxns hashes") } return txIDs, hashes, args.Error(2) } -// loadInvalidateTarget implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) loadInvalidateTarget(ctx context.Context, +// LoadInvalidateTarget implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, - txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { - var zeroTarget invalidateUnminedTxTarget + var zeroTarget InvalidateUnminedTxTarget args := m.Called(ctx, walletID, txHash) if args.Get(0) == nil { return zeroTarget, args.Error(1) } - target, ok := args.Get(0).(invalidateUnminedTxTarget) + target, ok := args.Get(0).(InvalidateUnminedTxTarget) if !ok { - return zeroTarget, mockTypeError("loadInvalidateTarget result") + return zeroTarget, mockTypeError("LoadInvalidateTarget result") } return target, args.Error(1) } -// listUnminedTxRecords implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) { +// ListUnminedTxRecords implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) { args := m.Called(ctx, walletID) if args.Get(0) == nil { return nil, args.Error(1) } - records, ok := args.Get(0).([]unminedTxRecord) + records, ok := args.Get(0).([]UnminedTxRecord) if !ok { - return nil, mockTypeError("listUnminedTxRecords result") + return nil, mockTypeError("ListUnminedTxRecords result") } return records, args.Error(1) } -// clearSpentUtxos implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, +// ClearSpentUtxos implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { args := m.Called(ctx, walletID, txID) @@ -654,8 +657,8 @@ func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, return args.Error(0) } -// markTxnsFailed implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, +// MarkTxnsFailed implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -663,8 +666,8 @@ func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, return args.Error(0) } -// markTxnsReplaced implements createTxOps. -func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, +// MarkTxnsReplaced implements CreateTxOps. +func (m *mockCreateTxOps) MarkTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -672,8 +675,8 @@ func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, return args.Error(0) } -// insertReplacementEdges implements createTxOps. -func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, +// InsertReplacementEdges implements CreateTxOps. +func (m *mockCreateTxOps) InsertReplacementEdges(ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { args := m.Called(ctx, walletID, replacedTxIDs, replacementTxID) @@ -681,61 +684,61 @@ func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, return args.Error(0) } -// insert implements createTxOps. -func (m *mockCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert implements CreateTxOps. +func (m *mockCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { args := m.Called(ctx, req) txID, ok := args.Get(0).(int64) if !ok { - return 0, mockTypeError("insert result") + return 0, mockTypeError("Insert result") } return txID, args.Error(1) } -// insertCredits implements createTxOps. -func (m *mockCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits implements CreateTxOps. +func (m *mockCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { args := m.Called(ctx, req, txID) return args.Error(0) } -// markInputsSpent implements createTxOps. -func (m *mockCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent implements CreateTxOps. +func (m *mockCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { args := m.Called(ctx, req, txID) return args.Error(0) } -// mockUpdateTxOps is a mock implementation of updateTxOps. +// mockUpdateTxOps is a mock implementation of UpdateTxOps. type mockUpdateTxOps struct { mock.Mock } -var _ updateTxOps = (*mockUpdateTxOps)(nil) +var _ UpdateTxOps = (*mockUpdateTxOps)(nil) -// loadIsCoinbase implements updateTxOps. -func (m *mockUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, +// LoadIsCoinbase implements UpdateTxOps. +func (m *mockUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { args := m.Called(ctx, walletID, txHash) isCoinbase, ok := args.Get(0).(bool) if !ok { - return false, mockTypeError("loadIsCoinbase result") + return false, mockTypeError("LoadIsCoinbase result") } return isCoinbase, args.Error(1) } -// prepareState implements updateTxOps. -func (m *mockUpdateTxOps) prepareState(ctx context.Context, +// PrepareState implements UpdateTxOps. +func (m *mockUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { args := m.Called(ctx, state) @@ -743,8 +746,8 @@ func (m *mockUpdateTxOps) prepareState(ctx context.Context, return args.Error(0) } -// updateLabel implements updateTxOps. -func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel implements UpdateTxOps. +func (m *mockUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { args := m.Called(ctx, walletID, txHash, label) @@ -752,8 +755,8 @@ func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, return args.Error(0) } -// updateState implements updateTxOps. -func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, +// UpdateState implements UpdateTxOps. +func (m *mockUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, state UpdateTxState) error { args := m.Called(ctx, walletID, txHash, state) @@ -763,10 +766,10 @@ func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, // testCreateTxRequest builds one valid normalized CreateTx request for the // shared CreateTx orchestration tests. -func testCreateTxRequest(t *testing.T) createTxRequest { +func testCreateTxRequest(t *testing.T) CreateTxRequest { t.Helper() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -785,16 +788,16 @@ var errConflictMarkFailed = errors.New("mark failed") func TestCollectConflictDescendants(t *testing.T) { t.Parallel() - candidates := []unminedTxRecord{{ - id: 12, - hash: chainhash.Hash{2}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 12, + Hash: chainhash.Hash{2}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, }}}, }, { - id: 13, - hash: chainhash.Hash{3}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 13, + Hash: chainhash.Hash{3}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, }}}, }} @@ -806,7 +809,7 @@ func TestCollectConflictDescendants(t *testing.T) { rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() descendantIDs, err := collectConflictDescendants( @@ -824,7 +827,7 @@ func TestHandleTxConflicts(t *testing.T) { rootHash := chainhash.Hash{1} childHash := chainhash.Hash{2} grandchildHash := chainhash.Hash{3} - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -833,16 +836,16 @@ func TestHandleTxConflicts(t *testing.T) { }) require.NoError(t, err) - candidates := []unminedTxRecord{{ - id: 2, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 2, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, }}}, }, { - id: 3, - hash: grandchildHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: grandchildHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, }}}, }} @@ -852,29 +855,29 @@ func TestHandleTxConflicts(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{rootHash}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -890,7 +893,7 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { rootAHash := chainhash.Hash{1} rootBHash := chainhash.Hash{2} childHash := chainhash.Hash{3} - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -904,38 +907,38 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1, 2}, []chainhash.Hash{rootAHash, rootBHash}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord{{ - id: 2, - hash: rootBHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord{{ + ID: 2, + Hash: rootBHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootAHash, Index: 0}, }}}, }, { - id: 3, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, }}}, }}, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). Return(nil).Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -947,7 +950,7 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { func TestHandleTxConflictsNoDescendants(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -961,18 +964,18 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -984,7 +987,7 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { func TestHandleTxConflictsMarkFailedError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -998,15 +1001,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord{{ - id: 2, - hash: chainhash.Hash{2}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord{{ + ID: 2, + Hash: chainhash.Hash{2}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{ Hash: chainhash.Hash{1}, Index: 0, @@ -1014,15 +1017,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { }}}, }}, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( errConflictMarkFailed).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -1056,31 +1059,33 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + ops.On("LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() - ops.On("prepareState", mock.Anything, UpdateTxState{ + ops.On("PrepareState", mock.Anything, UpdateTxState{ Status: TxStatusPublished, }).Return(nil).Once() - ops.On("updateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, + ops.On("UpdateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, label).Return(nil).Run(func(args mock.Arguments) { labelArg, ok := args.Get(3).(string) require.True(t, ok) + updatedLabel = labelArg }).Once() - ops.On("updateState", mock.Anything, uint32(5), chainhash.Hash{1}, + ops.On("UpdateState", mock.Anything, uint32(5), chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}).Return(nil).Run( func(args mock.Arguments) { stateArg, ok := args.Get(3).(UpdateTxState) require.True(t, ok) + updatedState = stateArg }, ).Once() - // Act: Run updateTxWithOps against a stub backend adapter. - err := updateTxWithOps(context.Background(), params, ops) + // Act: Run UpdateTxWithOps against a stub backend adapter. + err := UpdateTxWithOps(context.Background(), params, ops) require.NoError(t, err) // Assert: The shared flow applies both patches. @@ -1102,10 +1107,10 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + ops.On("LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() - err := updateTxWithOps(context.Background(), params, ops) + err := UpdateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) } @@ -1169,10 +1174,10 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { }) ops.On( - "loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, + "LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, ).Return(test.isCoinbase, nil).Once() - err := updateTxWithOps(context.Background(), params, ops) + err := UpdateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) require.ErrorIs(t, err, ErrInvalidStatus) }) @@ -1185,7 +1190,7 @@ var errCreateTxTest = errors.New("create tx test") func TestCheckReuseCreateTx(t *testing.T) { t.Parallel() - coinbaseReq, err := newCreateTxRequest(CreateTxParams{ + coinbaseReq, err := NewCreateTxRequest(CreateTxParams{ WalletID: 9, Tx: testCoinbaseMsgTx(), Received: time.Unix(555, 0), @@ -1195,7 +1200,7 @@ func TestCheckReuseCreateTx(t *testing.T) { }) require.NoError(t, err) - confirmedReq, err := newCreateTxRequest(CreateTxParams{ + confirmedReq, err := NewCreateTxRequest(CreateTxParams{ WalletID: 9, Tx: testRegularMsgTx(), Received: time.Unix(556, 0), @@ -1207,55 +1212,55 @@ func TestCheckReuseCreateTx(t *testing.T) { tests := []struct { name string - req createTxRequest - existing createTxExistingTarget + req CreateTxRequest + existing CreateTxExistingTarget want bool }{ { name: "confirmed unmined row reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusPending, + existing: CreateTxExistingTarget{ + Status: TxStatusPending, }, want: true, }, { name: "missing block not reused", req: testCreateTxRequest(t), - existing: createTxExistingTarget{ - status: TxStatusPending, + existing: CreateTxExistingTarget{ + Status: TxStatusPending, }, }, { name: "existing confirmed row not reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusPublished, - hasBlock: true, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, }, }, { name: "non coinbase orphan not reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, }, }, { name: "orphaned coinbase reused", req: coinbaseReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, - isCoinbase: true, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, + IsCoinbase: true, }, want: true, }, { name: "coinbase row not reused for non coinbase tx", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, - isCoinbase: true, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, + IsCoinbase: true, }, }, } @@ -1280,22 +1285,22 @@ func TestLoadCreateTxExisting(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() existing, found, err := loadCreateTxExisting(context.Background(), req, ops) require.NoError(t, err) require.False(t, found) require.Nil(t, existing) - ops.On("loadExisting", mock.Anything, req).Return(nil, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return(nil, nil).Once() existing, found, err = loadCreateTxExisting(context.Background(), req, ops) require.NoError(t, err) require.False(t, found) require.Nil(t, existing) - ops.On("loadExisting", mock.Anything, req).Return( + ops.On("LoadExisting", mock.Anything, req).Return( nil, errCreateTxTest, ).Once() @@ -1314,7 +1319,7 @@ func TestHandleRootTxnsClearError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("clearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( errCreateTxTest).Once() err := handleRootTxns(context.Background(), 9, []int64{1}, 11, ops) @@ -1327,7 +1332,7 @@ func TestHandleRootTxnsClearError(t *testing.T) { func TestHandleTxConflictsEdgeError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1341,18 +1346,18 @@ func TestHandleTxConflictsEdgeError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil, ).Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( nil, ).Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) @@ -1365,7 +1370,7 @@ func TestHandleTxConflictsEdgeError(t *testing.T) { func TestHandleTxConflictsListError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1379,10 +1384,10 @@ func TestHandleTxConflictsListError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( nil, errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) @@ -1395,7 +1400,7 @@ func TestHandleTxConflictsListError(t *testing.T) { func TestHandleTxConflictsMarkReplacedError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1409,15 +1414,15 @@ func TestHandleTxConflictsMarkReplacedError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil, ).Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) diff --git a/wallet/internal/db/tx_store_invalidateunmined_common.go b/wallet/internal/db/tx_store_invalidateunmined_common.go index 1cc98cc9a0..9cb5a2b1ed 100644 --- a/wallet/internal/db/tx_store_invalidateunmined_common.go +++ b/wallet/internal/db/tx_store_invalidateunmined_common.go @@ -14,26 +14,26 @@ var ( ErrInvalidateTx = errors.New("invalidate tx") ) -// invalidateUnminedTxTarget is the normalized metadata the shared invalidation +// InvalidateUnminedTxTarget is the normalized metadata the shared invalidation // workflow needs for the root transaction. -type invalidateUnminedTxTarget struct { - // id is the backend row ID for the transaction being invalidated. - id int64 +type InvalidateUnminedTxTarget struct { + // ID is the backend row ID for the transaction being invalidated. + ID int64 - // txHash is the network transaction hash used for descendant discovery. - txHash chainhash.Hash + // TxHash is the network transaction hash used for descendant discovery. + TxHash chainhash.Hash - // status is the wallet-relative state that must still be unmined. - status TxStatus + // Status is the wallet-relative state that must still be unmined. + Status TxStatus - // hasBlock reports whether the row is already confirmed in a block. - hasBlock bool + // HasBlock reports whether the row is already confirmed in a block. + HasBlock bool - // isCoinbase reports whether the row is a coinbase transaction. - isCoinbase bool + // IsCoinbase reports whether the row is a coinbase transaction. + IsCoinbase bool } -// invalidateUnminedTxOps is the small backend adapter the shared +// InvalidateUnminedTxOps is the small backend adapter the shared // InvalidateUnminedTx workflow needs. // // The shared invalidation algorithm is intentionally ordered: @@ -49,47 +49,47 @@ type invalidateUnminedTxTarget struct { // invalid branch from retaining wallet-owned spend edges if any later step // fails. The backend adapters only supply query wiring and row-shape // conversions. -type invalidateUnminedTxOps interface { - // loadInvalidateTarget loads the wallet-scoped root tx metadata. - loadInvalidateTarget(ctx context.Context, walletID uint32, +type InvalidateUnminedTxOps interface { + // LoadInvalidateTarget loads the wallet-scoped root tx metadata. + LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) ( - invalidateUnminedTxTarget, error) + InvalidateUnminedTxTarget, error) - // listUnminedTxRecords loads the wallet's active unmined transaction rows + // ListUnminedTxRecords loads the wallet's active unmined transaction rows // in the normalized shape the descendant walk expects. - listUnminedTxRecords(ctx context.Context, walletID int64) ( - []unminedTxRecord, error) + ListUnminedTxRecords(ctx context.Context, walletID int64) ( + []UnminedTxRecord, error) - // clearSpentUtxos restores any wallet-owned parent outputs spent by the + // ClearSpentUtxos restores any wallet-owned parent outputs spent by the // given transaction row. - clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error + ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error - // markTxnsFailed batch-marks the provided tx rows as failed. - markTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error + // MarkTxnsFailed batch-marks the provided tx rows as failed. + MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error } // validateUnminedTxTarget checks that the requested root is a current // unmined non-coinbase transaction. -func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { - if target.hasBlock { - return fmt.Errorf("tx %s is confirmed: %w", target.txHash, +func validateUnminedTxTarget(target InvalidateUnminedTxTarget) error { + if target.HasBlock { + return fmt.Errorf("tx %s is confirmed: %w", target.TxHash, ErrInvalidateTx) } - if target.isCoinbase { - return fmt.Errorf("tx %s is coinbase: %w", target.txHash, + if target.IsCoinbase { + return fmt.Errorf("tx %s is coinbase: %w", target.TxHash, ErrInvalidateTx) } - if !isUnminedStatus(target.status) { - return fmt.Errorf("tx %s has status %d: %w", target.txHash, - target.status, ErrInvalidateTx) + if !IsUnminedStatus(target.Status) { + return fmt.Errorf("tx %s has status %d: %w", target.TxHash, + target.Status, ErrInvalidateTx) } return nil } -// invalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction +// InvalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction // root together with any descendant branch that depends on it. // // The helper performs descendant discovery before any spend-edge or status @@ -97,10 +97,10 @@ func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { // finally marks the combined branch failed. Keeping that ordering in one shared // helper ensures postgres and sqlite invalidate branches with identical wallet // semantics. -func invalidateUnminedTxWithOps(ctx context.Context, - params InvalidateUnminedTxParams, ops invalidateUnminedTxOps) error { +func InvalidateUnminedTxWithOps(ctx context.Context, + params InvalidateUnminedTxParams, ops InvalidateUnminedTxOps) error { - target, err := ops.loadInvalidateTarget(ctx, params.WalletID, params.Txid) + target, err := ops.LoadInvalidateTarget(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load invalidate tx target: %w", err) } @@ -110,32 +110,32 @@ func invalidateUnminedTxWithOps(ctx context.Context, return err } - candidates, err := ops.listUnminedTxRecords(ctx, int64(params.WalletID)) + candidates, err := ops.ListUnminedTxRecords(ctx, int64(params.WalletID)) if err != nil { return fmt.Errorf("list unmined invalidation txns: %w", err) } - descendantIDs := collectDescendantTxIDs( - []chainhash.Hash{target.txHash}, nil, candidates, + descendantIDs := CollectDescendantTxIDs( + []chainhash.Hash{target.TxHash}, nil, candidates, ) - err = ops.clearSpentUtxos(ctx, int64(params.WalletID), target.id) + err = ops.ClearSpentUtxos(ctx, int64(params.WalletID), target.ID) if err != nil { return fmt.Errorf("clear root spent utxos: %w", err) } for _, descendantID := range descendantIDs { - err = ops.clearSpentUtxos(ctx, int64(params.WalletID), descendantID) + err = ops.ClearSpentUtxos(ctx, int64(params.WalletID), descendantID) if err != nil { return fmt.Errorf("clear descendant spent utxos: %w", err) } } failedIDs := make([]int64, 0, len(descendantIDs)+1) - failedIDs = append(failedIDs, target.id) + failedIDs = append(failedIDs, target.ID) failedIDs = append(failedIDs, descendantIDs...) - err = ops.markTxnsFailed(ctx, int64(params.WalletID), failedIDs) + err = ops.MarkTxnsFailed(ctx, int64(params.WalletID), failedIDs) if err != nil { return fmt.Errorf("mark invalidated txns failed: %w", err) } diff --git a/wallet/internal/db/tx_store_invalidateunmined_common_test.go b/wallet/internal/db/tx_store_invalidateunmined_common_test.go index 73f6759f40..a105ffb8e8 100644 --- a/wallet/internal/db/tx_store_invalidateunmined_common_test.go +++ b/wallet/internal/db/tx_store_invalidateunmined_common_test.go @@ -15,49 +15,49 @@ var ( errInvalidateCommonTest = errors.New("invalidate common test") errInvalidateMockLoadInvalidateTargetType = errors.New( - "loadInvalidateTarget result is not invalidateUnminedTxTarget", + "LoadInvalidateTarget result is not InvalidateUnminedTxTarget", ) errInvalidateMockListUnminedType = errors.New( - "listUnminedTxRecords result is not []unminedTxRecord", + "ListUnminedTxRecords result is not []UnminedTxRecord", ) ) // mockInvalidateUnminedTxOps is a mock implementation of -// invalidateUnminedTxOps. +// InvalidateUnminedTxOps. type mockInvalidateUnminedTxOps struct { mock.Mock } -// loadInvalidateTarget implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, +// LoadInvalidateTarget implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, - txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { args := m.Called(ctx, walletID, txHash) if args.Get(0) == nil { - return invalidateUnminedTxTarget{}, args.Error(1) + return InvalidateUnminedTxTarget{}, args.Error(1) } - target, ok := args.Get(0).(invalidateUnminedTxTarget) + target, ok := args.Get(0).(InvalidateUnminedTxTarget) if !ok { - return invalidateUnminedTxTarget{}, + return InvalidateUnminedTxTarget{}, errInvalidateMockLoadInvalidateTargetType } return target, args.Error(1) } -// listUnminedTxRecords implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) { +// ListUnminedTxRecords implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) { args := m.Called(ctx, walletID) if args.Get(0) == nil { return nil, args.Error(1) } - records, ok := args.Get(0).([]unminedTxRecord) + records, ok := args.Get(0).([]UnminedTxRecord) if !ok { return nil, errInvalidateMockListUnminedType } @@ -65,8 +65,8 @@ func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, return records, args.Error(1) } -// clearSpentUtxos implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +// ClearSpentUtxos implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { args := m.Called(ctx, walletID, txID) @@ -74,8 +74,8 @@ func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return args.Error(0) } -// markTxnsFailed implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) markTxnsFailed(ctx context.Context, +// MarkTxnsFailed implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -90,56 +90,56 @@ func TestValidateInvalidateUnminedTxTarget(t *testing.T) { tests := []struct { name string - target invalidateUnminedTxTarget + target InvalidateUnminedTxTarget wantErr error }{ { name: "pending root", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{1}, - status: TxStatusPending, - hasBlock: false, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{1}, + Status: TxStatusPending, + HasBlock: false, }, }, { name: "published root", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{2}, - status: TxStatusPublished, - hasBlock: false, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{2}, + Status: TxStatusPublished, + HasBlock: false, }, }, { name: "confirmed root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{3}, - status: TxStatusPublished, - hasBlock: true, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{3}, + Status: TxStatusPublished, + HasBlock: true, }, wantErr: ErrInvalidateTx, }, { name: "failed root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{4}, - status: TxStatusFailed, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{4}, + Status: TxStatusFailed, }, wantErr: ErrInvalidateTx, }, { name: "coinbase root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{5}, - status: TxStatusPublished, - isCoinbase: true, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{5}, + Status: TxStatusPublished, + IsCoinbase: true, }, wantErr: ErrInvalidateTx, }, { name: "orphaned root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{6}, - status: TxStatusOrphaned, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{6}, + Status: TxStatusOrphaned, }, wantErr: ErrInvalidateTx, }, @@ -164,16 +164,16 @@ func TestInvalidateUnminedTxWithOps(t *testing.T) { childHash := chainhash.Hash{2} grandchildHash := chainhash.Hash{3} - candidates := []unminedTxRecord{{ - id: 2, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 2, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, }}}, }, { - id: 3, - hash: grandchildHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: grandchildHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, }}}, }} @@ -186,45 +186,49 @@ func TestInvalidateUnminedTxWithOps(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( - invalidateUnminedTxTarget{ - id: 1, - txHash: rootHash, - status: TxStatusPublished, + ops.On("LoadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( + InvalidateUnminedTxTarget{ + ID: 1, + TxHash: rootHash, + Status: TxStatusPublished, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( nil).Run(func(args mock.Arguments) { txIDs, ok := args.Get(2).([]int64) require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) }).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{WalletID: 7, Txid: rootHash}, ops, @@ -250,33 +254,35 @@ func TestInvalidateUnminedTxWithOpsNoDescendants(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{9}, ).Return( - invalidateUnminedTxTarget{ - id: 4, - txHash: chainhash.Hash{9}, - status: TxStatusPending, + InvalidateUnminedTxTarget{ + ID: 4, + TxHash: chainhash.Hash{9}, + Status: TxStatusPending, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(8)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("markTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( nil).Run(func(args mock.Arguments) { txIDs, ok := args.Get(2).([]int64) require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) }).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{WalletID: 8, Txid: chainhash.Hash{9}}, ops, @@ -297,11 +303,11 @@ func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{1}).Return( nil, errInvalidateCommonTest).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{ WalletID: 8, @@ -319,18 +325,18 @@ func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{2}).Return( - invalidateUnminedTxTarget{ - id: 5, - txHash: chainhash.Hash{2}, - status: TxStatusPublished, + InvalidateUnminedTxTarget{ + ID: 5, + TxHash: chainhash.Hash{2}, + Status: TxStatusPublished, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(8)).Return( nil, errInvalidateCommonTest).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{ WalletID: 8, diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 59414517df..c6a2756adf 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -21,25 +21,25 @@ var ( // another active lock on the same output. ErrOutputAlreadyLeased = errors.New("output already leased") - // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock + // ErrOutputUnlockNotAllowed reports that a UTXO Release request used a lock // ID different from the active lease. ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") - // errLeaseOutputNoRow indicates that the backend lease write found no + // ErrLeaseOutputNoRow indicates that the backend lease write found no // leasable current UTXO row for the requested outpoint. - errLeaseOutputNoRow = errors.New("lease output no row") + ErrLeaseOutputNoRow = errors.New("lease output no row") - // errReleaseOutputUtxoNotFound indicates that ReleaseOutput could not + // ErrReleaseOutputUtxoNotFound indicates that ReleaseOutput could not // resolve the requested outpoint to a current wallet-owned UTXO row. - errReleaseOutputUtxoNotFound = errors.New( - "release output utxo not found", + ErrReleaseOutputUtxoNotFound = errors.New( + "Release output utxo not found", ) - // errReleaseOutputNoActiveLease indicates that the target UTXO no longer + // ErrReleaseOutputNoActiveLease indicates that the target UTXO no longer // has an active lease row by the time ReleaseOutput checks the fallback // path. - errReleaseOutputNoActiveLease = errors.New( - "release output no active lease", + ErrReleaseOutputNoActiveLease = errors.New( + "Release output no active lease", ) ) @@ -54,9 +54,9 @@ func buildOutPoint(hash []byte, outputIndex uint32) (wire.OutPoint, error) { return wire.OutPoint{Hash: *txHash, Index: outputIndex}, nil } -// buildUtxoInfo converts normalized SQL result fields into the public UtxoInfo +// BuildUtxoInfo converts normalized SQL result fields into the public UtxoInfo // shape returned by the db interfaces. -func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, +func BuildUtxoInfo(hash []byte, outputIndex uint32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight *uint32) (*UtxoInfo, error) { @@ -80,9 +80,9 @@ func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, }, nil } -// buildLeasedOutput converts SQL lease-row fields into the public LeasedOutput +// BuildLeasedOutput converts SQL lease-row fields into the public LeasedOutput // type. -func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, +func BuildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, expiration time.Time) (*LeasedOutput, error) { outPoint, err := buildOutPoint(hash, outputIndex) @@ -104,7 +104,7 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, }, nil } -// leaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. +// LeaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. // // The shared lease algorithm is intentionally ordered: // - validate the public lease request up front @@ -115,25 +115,25 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, // // The adapter methods map directly to those stages so the shared helper keeps // the policy and sequencing while each backend keeps only query details. -type leaseOutputOps interface { - // acquire attempts to write or renew the lease and returns the stored +type LeaseOutputOps interface { + // Acquire attempts to write or renew the lease and returns the stored // expiration timestamp when the write succeeds. - acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, + Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) - // hasUtxo reports whether the requested outpoint still exists as a current + // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. - hasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) + HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) } -// leaseOutputWithOps runs the backend-independent LeaseOutput workflow once the +// LeaseOutputWithOps runs the backend-independent LeaseOutput workflow once the // caller has opened a backend-specific SQL transaction. // // The helper owns the lease sequencing so every backend answers the same two // questions in the same order: did the lease write succeed, and if not, was the // target output missing or merely already leased by a different lock? -func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, - ops leaseOutputOps) (*LeasedOutput, error) { +func LeaseOutputWithOps(ctx context.Context, params LeaseOutputParams, + ops LeaseOutputOps) (*LeasedOutput, error) { if params.Duration <= 0 { return nil, fmt.Errorf("%w: lease duration must be positive", @@ -143,7 +143,7 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, nowUTC := time.Now().UTC() expiresAt := nowUTC.Add(params.Duration) - expiration, err := ops.acquire(ctx, params, nowUTC, expiresAt) + expiration, err := ops.Acquire(ctx, params, nowUTC, expiresAt) if err == nil { return &LeasedOutput{ OutPoint: params.OutPoint, @@ -152,14 +152,14 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, }, nil } - if !errors.Is(err, errLeaseOutputNoRow) { + if !errors.Is(err, ErrLeaseOutputNoRow) { return nil, fmt.Errorf("acquire utxo lease: %w", err) } - // A no-row acquire means the write path found no leasable row. + // A no-row Acquire means the write path found no leasable row. // Distinguish a missing UTXO from an already-active lease before // returning a public error. - exists, err := ops.hasUtxo(ctx, params) + exists, err := ops.HasUtxo(ctx, params) if err != nil { return nil, fmt.Errorf("lookup utxo before lease conflict: %w", err) } @@ -173,10 +173,10 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, ErrOutputAlreadyLeased) } -// releaseOutputOps is the backend adapter the shared ReleaseOutput workflow +// ReleaseOutputOps is the backend adapter the shared ReleaseOutput workflow // uses. // -// The shared release algorithm is intentionally ordered: +// The shared Release algorithm is intentionally ordered: // - resolve the wallet-owned outpoint to a stable UTXO row first // - attempt the lease delete by lock ID second // - if no row is deleted, check the active lease state for that UTXO @@ -184,39 +184,39 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, // - map a different active lock to ErrOutputUnlockNotAllowed // // The adapter methods map directly to those stages so the shared helper keeps -// the release policy and sequencing while each backend keeps only query +// the Release policy and sequencing while each backend keeps only query // details. -type releaseOutputOps interface { - // lookupUtxoID resolves the current wallet-owned outpoint to its stable +type ReleaseOutputOps interface { + // LookupUtxoID resolves the current wallet-owned outpoint to its stable // UTXO row ID. - lookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) + LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) - // release attempts to delete the lease row for the provided UTXO ID and + // Release attempts to delete the lease row for the provided UTXO ID and // lock ID, returning the number of deleted rows. - release(ctx context.Context, walletID uint32, utxoID int64, + Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) - // activeLockID returns the currently active lock ID for the provided UTXO + // ActiveLockID returns the currently active lock ID for the provided UTXO // ID. - activeLockID(ctx context.Context, walletID uint32, utxoID int64, + ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) } -// releaseOutputWithOps runs the backend-independent ReleaseOutput workflow once +// ReleaseOutputWithOps runs the backend-independent ReleaseOutput workflow once // the caller has opened a backend-specific SQL transaction. // // The helper resolves the stable UTXO row first, attempts the lock-specific // delete second, and only falls back to the active-lock lookup when no row was // deleted. That keeps a released-or-expired lease as a no-op while still // surfacing conflicting active locks consistently across backends. -func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, - ops releaseOutputOps) error { +func ReleaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, + ops ReleaseOutputOps) error { nowUTC := time.Now().UTC() - utxoID, err := ops.lookupUtxoID(ctx, params) + utxoID, err := ops.LookupUtxoID(ctx, params) if err != nil { - if errors.Is(err, errReleaseOutputUtxoNotFound) { + if errors.Is(err, ErrReleaseOutputUtxoNotFound) { return fmt.Errorf("utxo %s: %w", params.OutPoint, ErrUtxoNotFound) } @@ -224,7 +224,7 @@ func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, return fmt.Errorf("lookup utxo for release: %w", err) } - rows, err := ops.release(ctx, params.WalletID, utxoID, params.ID) + rows, err := ops.Release(ctx, params.WalletID, utxoID, params.ID) if err != nil { return fmt.Errorf("release utxo lease: %w", err) } @@ -235,11 +235,11 @@ func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, // No row was deleted, so either the lease already expired or was released, // or a different active lock still owns this UTXO. - activeLockID, err := ops.activeLockID( + activeLockID, err := ops.ActiveLockID( ctx, params.WalletID, utxoID, nowUTC, ) if err != nil { - if errors.Is(err, errReleaseOutputNoActiveLease) { + if errors.Is(err, ErrReleaseOutputNoActiveLease) { return nil } diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 2582e2109f..2dba8083fb 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "testing" "time" @@ -65,7 +64,7 @@ func TestBuildOutPoint_InvalidHash(t *testing.T) { require.Error(t, err) } -// TestBuildUtxoInfo_Confirmed verifies that buildUtxoInfo preserves confirmed +// TestBuildUtxoInfo_Confirmed verifies that BuildUtxoInfo preserves confirmed // UTXO metadata. // // Scenario: @@ -85,7 +84,7 @@ func TestBuildUtxoInfo_Confirmed(t *testing.T) { confirmedHeight := uint32(33) // Act: Build the public UTXO view for the confirmed row. - confirmed, err := buildUtxoInfo( + confirmed, err := BuildUtxoInfo( hash[:], 1, 1234, []byte{0x51}, time.Unix(111, 0), true, &confirmedHeight, ) @@ -97,7 +96,7 @@ func TestBuildUtxoInfo_Confirmed(t *testing.T) { require.Equal(t, uint32(1), confirmed.OutPoint.Index) } -// TestBuildUtxoInfo_Unconfirmed verifies that buildUtxoInfo maps unconfirmed +// TestBuildUtxoInfo_Unconfirmed verifies that BuildUtxoInfo maps unconfirmed // rows to the public unmined sentinel. // // Scenario: @@ -117,7 +116,7 @@ func TestBuildUtxoInfo_Unconfirmed(t *testing.T) { hash := chainhash.Hash{9} // Act: Build the public UTXO view for the unconfirmed row. - unconfirmed, err := buildUtxoInfo( + unconfirmed, err := BuildUtxoInfo( hash[:], 2, 5678, []byte{0x52}, time.Unix(222, 0), false, nil, ) @@ -149,7 +148,7 @@ func TestBuildLeasedOutput(t *testing.T) { lockID[0] = 7 // Act: Build the public leased-output view. - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( hash[:], 9, lockID, time.Unix(333, 0).In(time.FixedZone("X", 3600)), ) @@ -161,7 +160,7 @@ func TestBuildLeasedOutput(t *testing.T) { require.Equal(t, time.UTC, lease.Expiration.Location()) } -// TestBuildLeasedOutput_InvalidLockID verifies that buildLeasedOutput rejects +// TestBuildLeasedOutput_InvalidLockID verifies that BuildLeasedOutput rejects // malformed lock IDs. // // Scenario: @@ -181,14 +180,14 @@ func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { shortLockID := []byte{1, 2, 3} // Act: Attempt to build the public leased-output view. - _, err := buildLeasedOutput(hash[:], 0, shortLockID, time.Now()) + _, err := BuildLeasedOutput(hash[:], 0, shortLockID, time.Now()) // Assert: The helper returns the invalid-lock-ID sentinel. require.ErrorIs(t, err, errInvalidLockID) } // TestLeaseOutputWithOps verifies that the shared LeaseOutput helper returns -// the leased outpoint when the backend acquire step succeeds. +// the leased outpoint when the backend Acquire step succeeds. func TestLeaseOutputWithOps(t *testing.T) { t.Parallel() @@ -206,11 +205,11 @@ func TestLeaseOutputWithOps(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( acquireExpiration, nil).Once() // Act: Run the shared LeaseOutput flow. - lease, err := leaseOutputWithOps(context.Background(), params, ops) + lease, err := LeaseOutputWithOps(context.Background(), params, ops) // Assert: The helper returns the leased outpoint and UTC expiration. require.NoError(t, err) @@ -235,14 +234,14 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { ops.AssertExpectations(t) }) - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - ops.AssertNotCalled(t, "acquire", mock.Anything, mock.Anything, + ops.AssertNotCalled(t, "Acquire", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } // TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper -// maps a no-row acquire and missing outpoint lookup to ErrUtxoNotFound. +// maps a no-row Acquire and missing outpoint lookup to ErrUtxoNotFound. func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { t.Parallel() @@ -258,17 +257,17 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( - time.Time{}, errLeaseOutputNoRow).Once() + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, ErrLeaseOutputNoRow).Once() - ops.On("hasUtxo", mock.Anything, params).Return(false, nil).Once() + ops.On("HasUtxo", mock.Anything, params).Return(false, nil).Once() - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) } // TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput -// helper maps a no-row acquire and existing outpoint to ErrOutputAlreadyLeased. +// helper maps a no-row Acquire and existing outpoint to ErrOutputAlreadyLeased. func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { t.Parallel() @@ -284,12 +283,12 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( - time.Time{}, errLeaseOutputNoRow).Once() + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, ErrLeaseOutputNoRow).Once() - ops.On("hasUtxo", mock.Anything, params).Return(true, nil).Once() + ops.On("HasUtxo", mock.Anything, params).Return(true, nil).Once() - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputAlreadyLeased) } @@ -298,7 +297,7 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { func TestReleaseOutputWithOps(t *testing.T) { t.Parallel() - // Arrange: Build one valid release request and one successful stub adapter. + // Arrange: Build one valid Release request and one successful stub adapter. params := ReleaseOutputParams{ WalletID: 5, OutPoint: testLeaseOutPoint(), @@ -309,13 +308,13 @@ func TestReleaseOutputWithOps(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(1), nil).Once() // Act: Run the shared ReleaseOutput flow. - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) // Assert: The helper stops after the successful delete path. require.NoError(t, err) @@ -336,10 +335,10 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("lookupUtxoID", mock.Anything, params).Return( - int64(0), errReleaseOutputUtxoNotFound).Once() + ops.On("LookupUtxoID", mock.Anything, params).Return( + int64(0), ErrReleaseOutputUtxoNotFound).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) } @@ -359,15 +358,16 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { }) releaseTimeMatcher := mock.AnythingOfType("time.Time") - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(0), nil).Once() - ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + ops.On("ActiveLockID", mock.Anything, uint32(5), int64(11), releaseTimeMatcher).Return([]byte{1, 2, 3}, nil).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) } @@ -388,27 +388,28 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { }) releaseTimeMatcher := mock.AnythingOfType("time.Time") - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(0), nil).Once() - ops.On("activeLockID", mock.Anything, uint32(5), int64(11), - releaseTimeMatcher).Return(nil, errReleaseOutputNoActiveLease).Once() + ops.On("ActiveLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return(nil, ErrReleaseOutputNoActiveLease).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.NoError(t, err) } -// mockLeaseOutputOps is a mock implementation of leaseOutputOps. +// mockLeaseOutputOps is a mock implementation of LeaseOutputOps. type mockLeaseOutputOps struct { mock.Mock } -var _ leaseOutputOps = (*mockLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*mockLeaseOutputOps)(nil) -// acquire implements leaseOutputOps. -func (m *mockLeaseOutputOps) acquire(ctx context.Context, +// Acquire implements LeaseOutputOps. +func (m *mockLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, start time.Time, expiration time.Time) (time.Time, error) { @@ -422,29 +423,29 @@ func (m *mockLeaseOutputOps) acquire(ctx context.Context, return leaseExpiration, args.Error(1) } -// hasUtxo implements leaseOutputOps. -func (m *mockLeaseOutputOps) hasUtxo(ctx context.Context, +// HasUtxo implements LeaseOutputOps. +func (m *mockLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { args := m.Called(ctx, params) - hasUtxo, ok := args.Get(0).(bool) + HasUtxo, ok := args.Get(0).(bool) if !ok { return false, errMockType } - return hasUtxo, args.Error(1) + return HasUtxo, args.Error(1) } -// mockReleaseOutputOps is a mock implementation of releaseOutputOps. +// mockReleaseOutputOps is a mock implementation of ReleaseOutputOps. type mockReleaseOutputOps struct { mock.Mock } -var _ releaseOutputOps = (*mockReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*mockReleaseOutputOps)(nil) -// lookupUtxoID implements releaseOutputOps. -func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, +// LookupUtxoID implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { args := m.Called(ctx, params) @@ -457,8 +458,8 @@ func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, args.Error(1) } -// release implements releaseOutputOps. -func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, +// Release implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { args := m.Called(ctx, walletID, utxoID, lockID) @@ -471,8 +472,8 @@ func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, return releasedRows, args.Error(1) } -// activeLockID implements releaseOutputOps. -func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, +// ActiveLockID implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, now time.Time) ([]byte, error) { args := m.Called(ctx, walletID, utxoID, now) @@ -494,13 +495,13 @@ func testLeaseOutPoint() wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} } -// TestBuildUtxoInfoMaxAmount verifies that buildUtxoInfo preserves the largest +// TestBuildUtxoInfoMaxAmount verifies that BuildUtxoInfo preserves the largest // valid satoshi amount. func TestBuildUtxoInfoMaxAmount(t *testing.T) { t.Parallel() hash := chainhash.Hash{10} - info, err := buildUtxoInfo( + info, err := BuildUtxoInfo( hash[:], 3, int64(btcutil.MaxSatoshi), []byte{0x53}, time.Unix(333, 0), false, nil, ) @@ -510,81 +511,25 @@ func TestBuildUtxoInfoMaxAmount(t *testing.T) { require.Equal(t, UnminedHeight, info.Height) } -// TestBuildUtxoInfoInvalidHash verifies that buildUtxoInfo rejects malformed +// TestBuildUtxoInfoInvalidHash verifies that BuildUtxoInfo rejects malformed // hash bytes. func TestBuildUtxoInfoInvalidHash(t *testing.T) { t.Parallel() - _, err := buildUtxoInfo( + _, err := BuildUtxoInfo( []byte{1, 2, 3}, 6, 1000, []byte{0x56}, time.Unix(666, 0), false, nil, ) require.Error(t, err) } -// TestBuildLeasedOutputInvalidHash verifies that buildLeasedOutput rejects +// TestBuildLeasedOutputInvalidHash verifies that BuildLeasedOutput rejects // malformed hash bytes. func TestBuildLeasedOutputInvalidHash(t *testing.T) { t.Parallel() lockID := make([]byte, 32) - _, err := buildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) + _, err := BuildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) require.Error(t, err) } - -// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies that the sqlite row -// decoder rejects output indexes outside the uint32 range. -func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{13} - _, err := utxoInfoFromSqliteRow( - hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, - sql.NullInt64{}, - ) - - require.ErrorContains(t, err, "utxo output index") -} - -// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies that the sqlite row -// decoder rejects invalid confirmed block heights. -func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{14} - _, err := utxoInfoFromSqliteRow( - hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, - sql.NullInt64{Int64: -1, Valid: true}, - ) - - require.ErrorContains(t, err, "utxo block height") -} - -// TestUtxoInfoFromPgRowInvalidOutputIndex verifies that the postgres row -// decoder rejects output indexes outside the uint32 range. -func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{15} - _, err := utxoInfoFromPgRow( - hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, - sql.NullInt32{}, - ) - - require.ErrorContains(t, err, "utxo output index") -} - -// TestUtxoInfoFromPgRowInvalidBlockHeight verifies that the postgres row -// decoder rejects invalid confirmed block heights. -func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{16} - _, err := utxoInfoFromPgRow( - hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, - sql.NullInt32{Int32: -1, Valid: true}, - ) - - require.ErrorContains(t, err, "utxo block height") -} diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index d0f2277b82..8d3f212c77 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -178,7 +178,7 @@ func (s *PostgresStore) IterWallets(ctx context.Context, query ListWalletsQuery) iter.Seq2[WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, nextListWalletsQuery, + ctx, query, s.ListWallets, NextListWalletsQuery, ) } @@ -337,7 +337,7 @@ func pgListWalletsParams( // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { - walletID, err := int64ToUint32(row.id) + walletID, err := Int64ToUint32(row.id) if err != nil { return nil, err } @@ -393,7 +393,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.SyncedTo != nil { - syncedHeight, err := uint32ToNullInt32(params.SyncedTo.Height) + syncedHeight, err := Uint32ToNullInt32(params.SyncedTo.Height) if err != nil { return syncParams, err } @@ -409,7 +409,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.BirthdayBlock != nil { - birthdayHeight, err := uint32ToNullInt32( + birthdayHeight, err := Uint32ToNullInt32( params.BirthdayBlock.Height, ) if err != nil { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 6ce787db31..e0821b4b2f 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -180,7 +180,7 @@ func (s *SqliteStore) IterWallets(ctx context.Context, query ListWalletsQuery) iter.Seq2[WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, nextListWalletsQuery, + ctx, query, s.ListWallets, NextListWalletsQuery, ) } @@ -337,12 +337,12 @@ func sqliteListWalletsParams( // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { - walletID, err := int64ToUint32(row.id) + walletID, err := Int64ToUint32(row.id) if err != nil { return nil, err } - mgrVer, err := int64ToInt32(row.managerVersion) + mgrVer, err := Int64ToInt32(row.managerVersion) if err != nil { return nil, err } diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go index cb3ee35be1..cfeb7b5f3d 100644 --- a/wallet/internal/db/wallets_common.go +++ b/wallet/internal/db/wallets_common.go @@ -1,8 +1,8 @@ package db -// nextListWalletsQuery returns a query with its pagination cursor advanced to +// NextListWalletsQuery returns a query with its pagination cursor advanced to // the provided value. -func nextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { +func NextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { q.Page = q.Page.WithAfter(cursor) return q From 876bc197a5d5fdfb61c0b42cfd5c232623323fe8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:14:16 +0800 Subject: [PATCH 222/695] db/sqlite: move backend files Move the handwritten SQLite store implementation into wallet/internal/db/sqlite while keeping shared contracts and helper logic in wallet/internal/db. This keeps the split mostly mechanical and updates the sqlite-specific tests and itest helpers to import the new package. The move also shifts sqlite-only backend tests out of the root db package so the root tests no longer depend on sqlite-private symbols. --- wallet/internal/db/accounts_common.go | 16 +- wallet/internal/db/accounts_pg.go | 2 +- wallet/internal/db/addresses_common.go | 18 +- wallet/internal/db/addresses_pg.go | 2 +- wallet/internal/db/db_connectors_test.go | 40 +++-- wallet/internal/db/db_itest.go | 11 -- .../internal/db/itest/fixtures_sqlite_test.go | 5 +- wallet/internal/db/itest/sqlite_test.go | 17 +- .../db/itest/tx_corruption_sqlite_test.go | 11 +- .../accounts.go} | 103 +++++------ .../address_types.go} | 19 +- .../addresses.go} | 87 +++++----- .../internal/db/sqlite/backend_error_test.go | 105 +++++++++++ .../internal/db/sqlite/backend_rows_test.go | 122 +++++++++++++ .../db/{block_sqlite.go => sqlite/block.go} | 19 +- wallet/internal/db/sqlite/itest.go | 19 ++ .../db/{sqlite.go => sqlite/store.go} | 33 ++-- wallet/internal/db/sqlite/testhelpers_test.go | 163 ++++++++++++++++++ .../txstore_createtx.go} | 57 +++--- .../txstore_deletetx.go} | 23 +-- .../txstore_gettx.go} | 13 +- .../txstore_invalidateunmined.go} | 31 ++-- .../txstore_listtxns.go} | 15 +- .../txstore_rollback.go} | 19 +- .../txstore_updatetx.go} | 19 +- wallet/internal/db/sqlite/utxo_extra_test.go | 34 ++++ .../utxostore_balance.go} | 17 +- .../utxostore_getutxo.go} | 15 +- .../utxostore_leaseoutput.go} | 19 +- .../utxostore_listleasedoutputs.go} | 11 +- .../utxostore_listutxos.go} | 13 +- .../utxostore_releaseoutput.go} | 21 +-- .../db/{wallet_sqlite.go => sqlite/wallet.go} | 51 +++--- .../db/tx_store_backend_error_test.go | 103 ----------- .../internal/db/tx_store_backend_rows_test.go | 117 ------------- 35 files changed, 809 insertions(+), 561 deletions(-) rename wallet/internal/db/{accounts_sqlite.go => sqlite/accounts.go} (80%) rename wallet/internal/db/{address_types_sqlite.go => sqlite/address_types.go} (68%) rename wallet/internal/db/{addresses_sqlite.go => sqlite/addresses.go} (79%) create mode 100644 wallet/internal/db/sqlite/backend_error_test.go create mode 100644 wallet/internal/db/sqlite/backend_rows_test.go rename wallet/internal/db/{block_sqlite.go => sqlite/block.go} (81%) create mode 100644 wallet/internal/db/sqlite/itest.go rename wallet/internal/db/{sqlite.go => sqlite/store.go} (73%) create mode 100644 wallet/internal/db/sqlite/testhelpers_test.go rename wallet/internal/db/{sqlite_txstore_createtx.go => sqlite/txstore_createtx.go} (90%) rename wallet/internal/db/{sqlite_txstore_deletetx.go => sqlite/txstore_deletetx.go} (89%) rename wallet/internal/db/{sqlite_txstore_gettx.go => sqlite/txstore_gettx.go} (81%) rename wallet/internal/db/{sqlite_txstore_invalidateunmined.go => sqlite/txstore_invalidateunmined.go} (77%) rename wallet/internal/db/{sqlite_txstore_listtxns.go => sqlite/txstore_listtxns.go} (87%) rename wallet/internal/db/{sqlite_txstore_rollback.go => sqlite/txstore_rollback.go} (91%) rename wallet/internal/db/{sqlite_txstore_updatetx.go => sqlite/txstore_updatetx.go} (86%) create mode 100644 wallet/internal/db/sqlite/utxo_extra_test.go rename wallet/internal/db/{sqlite_utxostore_balance.go => sqlite/utxostore_balance.go} (55%) rename wallet/internal/db/{sqlite_utxostore_getutxo.go => sqlite/utxostore_getutxo.go} (81%) rename wallet/internal/db/{sqlite_utxostore_leaseoutput.go => sqlite/utxostore_leaseoutput.go} (81%) rename wallet/internal/db/{sqlite_utxostore_listleasedoutputs.go => sqlite/utxostore_listleasedoutputs.go} (75%) rename wallet/internal/db/{sqlite_utxostore_listutxos.go => sqlite/utxostore_listutxos.go} (70%) rename wallet/internal/db/{sqlite_utxostore_releaseoutput.go => sqlite/utxostore_releaseoutput.go} (81%) rename wallet/internal/db/{wallet_sqlite.go => sqlite/wallet.go} (89%) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 2bb6317261..85c76b4d66 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -21,8 +21,8 @@ var ( errInvalidAccountOrigin = errors.New("invalid account origin") ) -// validate validates required fields for creating a derived account. -func (params *CreateDerivedAccountParams) validate() error { +// Validate validates required fields for creating a derived account. +func (params *CreateDerivedAccountParams) Validate() error { if params.Name == "" { return ErrMissingAccountName } @@ -30,8 +30,8 @@ func (params *CreateDerivedAccountParams) validate() error { return nil } -// validate validates required fields for creating an imported account. -func (params *CreateImportedAccountParams) validate() error { +// Validate validates required fields for creating an imported account. +func (params *CreateImportedAccountParams) Validate() error { if params.Name == "" { return ErrMissingAccountName } @@ -43,8 +43,8 @@ func (params *CreateImportedAccountParams) validate() error { return nil } -// isWatchOnly returns true if the account is watch-only. -func (params *CreateImportedAccountParams) isWatchOnly() bool { +// IsWatchOnly returns true if the account is watch-only. +func (params *CreateImportedAccountParams) IsWatchOnly() bool { return len(params.EncryptedPrivateKey) == 0 } @@ -511,12 +511,12 @@ func CreateImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( getProps func(accountID int64) (*AccountProperties, error), ) (*AccountProperties, error) { - err := params.validate() + err := params.Validate() if err != nil { return nil, err } - isWatchOnly := params.isWatchOnly() + isWatchOnly := params.IsWatchOnly() scopeID, err := ensureScope() if err != nil { diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 72c2f54860..257be1d3e8 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -51,7 +51,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { - paramsErr := params.validate() + paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index db022a33a1..74238117b6 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -78,9 +78,9 @@ func GetAddressSecret[Row any](ctx context.Context, return nil, fmt.Errorf("get address secret: %w", err) } -// validate validates the required fields for creating an imported address. +// Validate validates the required fields for creating an imported address. // Returns sentinel errors on failure. -func (p NewImportedAddressParams) validate() error { +func (p NewImportedAddressParams) Validate() error { if len(p.ScriptPubKey) == 0 { return ErrMissingScriptPubKey } @@ -88,9 +88,9 @@ func (p NewImportedAddressParams) validate() error { return nil } -// isWatchOnly returns true if the params include neither a private key nor +// IsWatchOnly returns true if the params include neither a private key nor // a redeem or witness script. -func (p NewImportedAddressParams) isWatchOnly() bool { +func (p NewImportedAddressParams) IsWatchOnly() bool { noPrivKey := len(p.EncryptedPrivateKey) == 0 noScript := len(p.EncryptedScript) == 0 @@ -227,7 +227,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( } addrID := rowID(addrRow) - if !params.isWatchOnly() { + if !params.IsWatchOnly() { err = insertFn(qtx)(ctx, insertArgs(addrID, params)) if err != nil { return nil, fmt.Errorf("insert address secret: %w", err) @@ -247,7 +247,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( Origin: ImportedAccount, ScriptPubKey: params.ScriptPubKey, PubKey: params.PubKey, - IsWatchOnly: params.isWatchOnly(), + IsWatchOnly: params.IsWatchOnly(), }, nil } @@ -301,9 +301,9 @@ func convertAddressPath(origin AccountOrigin, branch, return addrBranch, addrIndex, nil } -// addressRowToInfo converts raw database field values into an AddressInfo +// AddressRowToInfo converts raw database field values into an AddressInfo // struct. It handles type conversion and validation for each field. -func addressRowToInfo[TypeID, OriginIDType any]( +func AddressRowToInfo[TypeID, OriginIDType any]( row AddressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { id, accountID, err := convertAddressIDs(row.ID, row.AccountID) @@ -611,7 +611,7 @@ func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, adapters ImportedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, AddrRow, SecretParams]) (*AddressInfo, error) { - validationErr := params.validate() + validationErr := params.Validate() if validationErr != nil { return nil, validationErr } diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 0d01de2b03..8aba77a7a1 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -290,7 +290,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(AddressInfoRow[int16, int16]{ + info, err := AddressRowToInfo(AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 8c9ec46d55..a8b37a7d2d 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -1,9 +1,11 @@ -package db +package db_test import ( "path/filepath" "testing" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -12,23 +14,23 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg PostgresConfig + cfg db.PostgresConfig wantErr error }{ { name: "empty DSN", - cfg: PostgresConfig{ + cfg: db.PostgresConfig{ Dsn: "", }, - wantErr: ErrEmptyDSN, + wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: PostgresConfig{ + cfg: db.PostgresConfig{ Dsn: "postgres://test", MaxConnections: -1, }, - wantErr: ErrNegativeMaxConns, + wantErr: db.ErrNegativeMaxConns, }, } @@ -36,7 +38,7 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := NewPostgresStore(t.Context(), tc.cfg) + store, err := db.NewPostgresStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -47,15 +49,15 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := PostgresConfig{ + cfg := db.PostgresConfig{ Dsn: "postgres://localhost:1/testdb", } - store, err := NewPostgresStore(t.Context(), cfg) + store, err := db.NewPostgresStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") - require.NotErrorIs(t, err, ErrEmptyDSN) - require.NotErrorIs(t, err, ErrNegativeMaxConns) + require.NotErrorIs(t, err, db.ErrEmptyDSN) + require.NotErrorIs(t, err, db.ErrNegativeMaxConns) // We are asserting nil here because it's not an integration test, so we // are not able to create a postgres database and connect to it. @@ -67,23 +69,23 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg SqliteConfig + cfg db.SqliteConfig wantErr error }{ { name: "empty DB path", - cfg: SqliteConfig{ + cfg: db.SqliteConfig{ DBPath: "", }, - wantErr: ErrEmptyDBPath, + wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: SqliteConfig{ + cfg: db.SqliteConfig{ DBPath: "/tmp/test.db", MaxConnections: -1, }, - wantErr: ErrNegativeMaxConns, + wantErr: db.ErrNegativeMaxConns, }, } @@ -91,7 +93,7 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := NewSqliteStore(t.Context(), tc.cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -101,11 +103,11 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { func TestNewSqliteStoreSuccess(t *testing.T) { t.Parallel() - cfg := SqliteConfig{ + cfg := db.SqliteConfig{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go index 85ca82369f..fb53c7271a 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/db_itest.go @@ -6,19 +6,8 @@ import ( "database/sql" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// DB returns the underlying *sql.DB connection for integration testing. -func (s *SqliteStore) DB() *sql.DB { - return s.db -} - -// Queries returns the underlying sqlc queries for integration testing. -func (s *SqliteStore) Queries() *sqlcsqlite.Queries { - return s.queries -} - // DB returns the underlying *sql.DB connection for integration testing. func (s *PostgresStore) DB() *sql.DB { return s.db diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index de5a4783bd..a3a4cae69c 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -189,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &db.SqliteStore{}, store) + require.IsType(t, &dbsqlite.SqliteStore{}, store) - sqliteStore := store.(*db.SqliteStore) + sqliteStore := store.(*dbsqlite.SqliteStore) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 519b1884d0..dc4c8dce27 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -12,12 +12,13 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *db.SqliteStore { +func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { t.Helper() tmpDir := t.TempDir() @@ -28,7 +29,7 @@ func NewTestStore(t *testing.T) *db.SqliteStore { MaxConnections: 0, } - store, err := db.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -40,7 +41,8 @@ func NewTestStore(t *testing.T) *db.SqliteStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, +func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, + walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() @@ -72,7 +74,7 @@ func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -96,7 +98,7 @@ func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -114,7 +116,7 @@ func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -135,7 +137,8 @@ func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *db.SqliteStore, walletID uint32, +func walletUtxoExists(t *testing.T, store *dbsqlite.SqliteStore, + walletID uint32, outPoint wire.OutPoint) bool { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index 6197534d12..bd5209823b 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -19,7 +20,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, +func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -54,7 +55,7 @@ func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *db.SqliteStore, +func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -89,7 +90,7 @@ func corruptTransactionHash(t *testing.T, store *db.SqliteStore, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -134,7 +135,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -170,7 +171,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *db.SqliteStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/sqlite/accounts.go similarity index 80% rename from wallet/internal/db/accounts_sqlite.go rename to wallet/internal/db/sqlite/accounts.go index d61a46111d..50a656dad8 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -1,34 +1,37 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // Ensure SqliteStore satisfies the AccountStore interface. -var _ AccountStore = (*SqliteStore)(nil) +var _ db.AccountStore = (*SqliteStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. func (s *SqliteStore) GetAccount(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := sqliteAccountGetQueries{q: s.queries} - return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. func (s *SqliteStore) ListAccounts(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := sqliteAccountListQueries{q: s.queries} - return ListAccountsByQuery( + return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -36,11 +39,11 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. func (s *SqliteStore) RenameAccount(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { renameQueries := sqliteAccountRenameQueries{q: s.queries} - return RenameAccountByQuery( + return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -49,14 +52,14 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, - params CreateDerivedAccountParams) (*AccountInfo, error) { + params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { - paramsErr := params.validate() + paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } - var info *AccountInfo + var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { scopeID, err := sqliteEnsureKeyScope( @@ -70,7 +73,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, ctx, sqlcsqlite.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int64(DerivedAccount), + OriginID: int64(db.DerivedAccount), IsWatchOnly: false, }, ) @@ -81,16 +84,16 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return ErrNilDBAccountNumber + return db.ErrNilDBAccountNumber } - accNumber, err := Int64ToUint32(row.AccountNumber.Int64) + accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) } - info = BuildAccountInfo( - accNumber, params.Name, DerivedAccount, 0, 0, 0, false, + info = db.BuildAccountInfo( + accNumber, params.Name, db.DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -106,16 +109,16 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, // sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, - walletID uint32, scope KeyScope) (int64, error) { + walletID uint32, scope db.KeyScope) (int64, error) { - return EnsureKeyScope( + return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { + func(addrSchema db.ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { return sqlcsqlite.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), @@ -138,14 +141,14 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. func (s *SqliteStore) CreateImportedAccount(ctx context.Context, - params CreateImportedAccountParams) (*AccountProperties, error) { + params db.CreateImportedAccountParams) (*db.AccountProperties, error) { - var props *AccountProperties + var props *db.AccountProperties err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error - props, err = CreateImportedAccount( + props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { return sqliteEnsureKeyScope( @@ -158,7 +161,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, return row.ID }, qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), - func(accountID int64) (*AccountProperties, error) { + func(accountID int64) (*db.AccountProperties, error) { return sqliteGetAccountProps(ctx, qtx, accountID) }, ) @@ -175,7 +178,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, // sqliteBuildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for SQLite. func sqliteBuildCreateImportedAccountArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { return func(scopeID int64, @@ -184,7 +187,7 @@ func sqliteBuildCreateImportedAccountArgs( return sqlcsqlite.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int64(ImportedAccount), + OriginID: int64(db.ImportedAccount), EncryptedPublicKey: params.EncryptedPublicKey, MasterFingerprint: sql.NullInt64{ Int64: int64(params.MasterFingerprint), @@ -198,7 +201,7 @@ func sqliteBuildCreateImportedAccountArgs( // sqliteBuildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for SQLite. func sqliteBuildCreateAccountSecretArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64) sqlcsqlite.CreateAccountSecretParams { return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { @@ -212,14 +215,14 @@ func sqliteBuildCreateAccountSecretArgs( // sqliteGetAccountProps fetches full account properties from the database and // converts the row to AccountProperties. func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, - accountID int64) (*AccountProperties, error) { + accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) if err != nil { return nil, fmt.Errorf("get account props: %w", err) } - return AccountPropsRowToProps(AccountPropsRow[int64, int64]{ + return db.AccountPropsRowToProps(db.AccountPropsRow[int64, int64]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -234,8 +237,8 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: IDToAddressType[int64], - IDToOriginType: IDToAccountOrigin[int64], + IDToAddrType: db.IDToAddressType[int64], + IDToOriginType: db.IDToAccountOrigin[int64], }) } @@ -255,14 +258,14 @@ type sqliteAccountInfoRow interface { // sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo // struct. It uses type conversion since all sqliteAccountInfoRow types have // identical fields. -func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, +func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAccountByScopeAndNameRow(row) - return AccountRowToInfo(AccountInfoRow[int64]{ + return db.AccountRowToInfo(db.AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -273,7 +276,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: IDToAccountOrigin[int64], + IDToOriginType: db.IDToAccountOrigin[int64], }) } @@ -284,9 +287,9 @@ type sqliteAccountListQueries struct { // byScope lists accounts filtered by wallet ID and key scope. func (s sqliteAccountListQueries) byScope(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWalletScope, sqlcsqlite.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -298,9 +301,9 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, // byName lists accounts filtered by wallet ID and account name. func (s sqliteAccountListQueries) byName(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWalletAndName, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -311,9 +314,9 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, // all lists all accounts for a wallet. func (s sqliteAccountListQueries) all(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), sqliteAccountRowToInfo, ) @@ -326,24 +329,24 @@ type sqliteAccountGetQueries struct { // byNumber retrieves an account by wallet ID, scope, and account number. func (s sqliteAccountGetQueries) byNumber(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. func (s sqliteAccountGetQueries) byName(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, + return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -361,16 +364,16 @@ type sqliteAccountRenameQueries struct { // byNumber renames an account identified by wallet ID, scope, and account // number. func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -378,9 +381,9 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. func (s sqliteAccountRenameQueries) byName(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/sqlite/address_types.go similarity index 68% rename from wallet/internal/db/address_types_sqlite.go rename to wallet/internal/db/sqlite/address_types.go index 28aaaf0433..d95b24593b 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -1,22 +1,23 @@ -package db +package sqlite import ( "context" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // sqliteAddressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, +func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, error) { - addrType, err := IDToAddressType(row.ID) + addrType, err := db.IDToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, err + return db.AddressTypeInfo{}, err } - return AddressTypeInfo{ + return db.AddressTypeInfo{ Type: addrType, Description: row.Description, }, nil @@ -25,9 +26,9 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( - []AddressTypeInfo, error) { + []db.AddressTypeInfo, error) { - return ListAddressTypes( + return db.ListAddressTypes( ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } @@ -35,9 +36,9 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. func (s *SqliteStore) GetAddressType(ctx context.Context, - id AddressType) (AddressTypeInfo, error) { + id db.AddressType) (db.AddressTypeInfo, error) { - return GetAddressTypeByID( + return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/sqlite/addresses.go similarity index 79% rename from wallet/internal/db/addresses_sqlite.go rename to wallet/internal/db/sqlite/addresses.go index 5645297197..a99357e37d 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "context" @@ -7,21 +7,22 @@ import ( "iter" "time" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -var _ AddressStore = (*SqliteStore)(nil) +var _ db.AddressStore = (*SqliteStore)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *SqliteStore) GetAddress(ctx context.Context, - query GetAddressQuery) (*AddressInfo, error) { + query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, - error) { + getByScript := func(ctx context.Context, + q db.GetAddressQuery) (*db.AddressInfo, error) { - return GetAddress( + return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), @@ -30,21 +31,21 @@ func (s *SqliteStore) GetAddress(ctx context.Context, ) } - return GetAddressByQuery(ctx, query, getByScript) + return db.GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. func (s *SqliteStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := sqliteListAddressesByAccount(ctx, s.queries, query) if err != nil { - return page.Result[AddressInfo, uint32]{}, err + return page.Result[db.AddressInfo, uint32]{}, err } result := page.BuildResult( query.Page, items, - func(item AddressInfo) uint32 { + func(item db.AddressInfo) uint32 { return item.ID }, ) @@ -54,18 +55,18 @@ func (s *SqliteStore) ListAddresses(ctx context.Context, // IterAddresses returns an iterator over paginated address results. func (s *SqliteStore) IterAddresses(ctx context.Context, - query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, NextListAddressesQuery, + ctx, query, s.ListAddresses, db.NextListAddressesQuery, ) } // GetAddressSecret retrieves the encrypted secret information for an address. func (s *SqliteStore) GetAddressSecret(ctx context.Context, - addressID uint32) (*AddressSecret, error) { + addressID uint32) (*db.AddressSecret, error) { - return GetAddressSecret( + return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) @@ -74,16 +75,16 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *SqliteStore) NewDerivedAddress(ctx context.Context, - params NewDerivedAddressParams, - deriveFn AddressDerivationFunc) (*AddressInfo, error) { + params db.NewDerivedAddressParams, + deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { - adapters := DerivedAddressAdapters[ + adapters := db.DerivedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ GetAccount: sqliteGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromParams, + AccountParams: db.AccountKeyFromParams, GetAccountID: newDerivedAddressGetAccountIDSQLite, GetExtIndex: newDerivedAddressGetExtIndexSQLite, GetIntIndex: newDerivedAddressGetIntIndexSQLite, @@ -92,22 +93,24 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx( + ctx, params, s.ExecuteTx, adapters, deriveFn, + ) } // NewImportedAddress imports a new address, script, or private key. func (s *SqliteStore) NewImportedAddress(ctx context.Context, - params NewImportedAddressParams) (*AddressInfo, error) { + params db.NewImportedAddressParams) (*db.AddressInfo, error) { - adapters := ImportedAddressAdapters[ + adapters := db.ImportedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ GetAccount: sqliteGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromImportedParams, + AccountParams: db.AccountKeyFromImportedParams, GetAccountID: newImportedAddressGetAccountIDSQLite, CreateAddr: sqliteCreateImportedAddress, CreateParams: createImportedAddressParamsSQLite, @@ -117,15 +120,15 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAtSQLite, } - return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( @@ -162,10 +165,12 @@ func newDerivedAddressGetIntIndexSQLite( // newDerivedAddressCreateAddrSQLite returns the derived address insert helper. func newDerivedAddressCreateAddrSQLite( - qtx *sqlcsqlite.Queries) func(context.Context, int64, AddressType, uint32, - uint32, []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + qtx *sqlcsqlite.Queries, +) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( + sqlcsqlite.CreateDerivedAddressRow, error, +) { - return func(ctx context.Context, accountID int64, addrType AddressType, + return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { @@ -226,7 +231,7 @@ func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, // createImportedAddressParamsSQLite maps imported params to sqlc params. func createImportedAddressParamsSQLite(accountID int64, - params NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { return sqlcsqlite.CreateImportedAddressParams{ AccountID: accountID, @@ -250,7 +255,7 @@ func importedAddressRowCreatedAtSQLite( // insertAddressSecretParamsSQLite maps imported params to secret params. func insertAddressSecretParamsSQLite(addressID int64, - params NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { return sqlcsqlite.InsertAddressSecretParams{ AddressID: addressID, @@ -262,9 +267,9 @@ func insertAddressSecretParamsSQLite(addressID int64, // sqliteAddressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. func sqliteAddressSecretRowToSecret( - row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { + row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { - return AddressSecretRowToSecret(AddressSecretRow{ + return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -281,13 +286,13 @@ type sqliteAddressInfoRow interface { // sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo // struct. -func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, +func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(AddressInfoRow[int64, int64]{ + info, err := db.AddressRowToInfo(db.AddressInfoRow[int64, int64]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -299,8 +304,8 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: IDToAddressType[int64], - IDToOrigin: IDToOrigin[int64], + IDToAddrType: db.IDToAddressType[int64], + IDToOrigin: db.IDToOrigin[int64], }) if err != nil { return nil, err @@ -312,7 +317,7 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, // sqliteListAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, - query ListAddressesQuery) ([]AddressInfo, error) { + query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( ctx, sqliteBuildAddressPageParams(query), @@ -321,7 +326,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, return nil, fmt.Errorf("list addresses by account: %w", err) } - items := make([]AddressInfo, len(rows)) + items := make([]db.AddressInfo, len(rows)) for i, row := range rows { item, err := sqliteAddressRowToInfo(row) if err != nil { @@ -339,7 +344,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, // sqliteBuildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func sqliteBuildAddressPageParams( - q ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { params := sqlcsqlite.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go new file mode 100644 index 0000000000..7ff820bb72 --- /dev/null +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -0,0 +1,105 @@ +package sqlite + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// TestDeleteAndRollbackOpsWrapBackendErrors verifies sqlite delete and rollback +// error wrapping. +func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + deleteOps := sqliteDeleteTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.DeleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} + +// TestTxStoreOpsWrapBackendErrors verifies sqlite helper error wrapping. +func TestTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{qtx: qtx}, + } + invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + updateOps := &sqliteUpdateTxOps{qtx: qtx} + releaseOps := sqliteReleaseOutputOps{qtx: qtx} + + err := createOps.MarkTxnsReplaced(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.InsertReplacementEdges(t.Context(), 1, []int64{2}, 3) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentSqlite(t.Context(), qtx, db.CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: db.TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.MarkTxRootsOrphaned(t.Context(), 1, []chainhash.Hash{{1}}) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt64{} + updateOps.status = int64(db.TxStatusPublished) + err = updateOps.UpdateState( + t.Context(), 1, chainhash.Hash{1}, + db.UpdateTxState{Status: db.TxStatusPublished}, + ) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go new file mode 100644 index 0000000000..8714e533ae --- /dev/null +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -0,0 +1,122 @@ +package sqlite + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// TestCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx branches. +func TestCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + loadOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + }), + }, + } + + _, err := loadOps.LoadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := testBlock(8) + confirmOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow( + t, + "SELECT ?, ?, ?", + int64(block.Height), + block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + } + err = confirmOps.ConfirmExisting(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, db.CreateTxExistingTarget{}) + require.ErrorIs(t, err, db.ErrTxNotFound) + + prepareOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + }), + }, + } + err = prepareOps.PrepareBlock(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, + }) + require.ErrorContains(t, err, "get block by height") + + conflictOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + } + _, _, err = conflictOps.ListConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestReleaseOutputOpsAdditionalBranches covers remaining sqlite Release paths. +func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + })} + + _, err := ops.LookupUtxoID(context.Background(), db.ReleaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }) + require.ErrorContains(t, err, "lookup utxo row") + + _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) + require.ErrorContains(t, err, "lookup active lease row") +} + +// TestUpdateTxOpsAdditionalBranches covers remaining sqlite UpdateTx branches. +func TestUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + })} + stateOps := &sqliteUpdateTxOps{ + qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt64{}, + status: int64(db.TxStatusPublished), + } + labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.UpdateState( + ctx, 1, txHash, db.UpdateTxState{Status: db.TxStatusPublished}, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) + + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, db.ErrTxNotFound) +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/sqlite/block.go similarity index 81% rename from wallet/internal/db/block_sqlite.go rename to wallet/internal/db/sqlite/block.go index 39bbf9916c..abf6c938b3 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/sqlite/block.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "bytes" @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -13,20 +14,20 @@ import ( // buildSqliteBlock constructs a Block from the given SQLite block // fields. func buildSqliteBlock(height sql.NullInt64, hash []byte, - timestamp sql.NullInt64) (*Block, error) { + timestamp sql.NullInt64) (*db.Block, error) { - height32, err := Int64ToUint32(height.Int64) + height32, err := db.Int64ToUint32(height.Int64) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return BuildBlock(hash, height32, timestamp.Int64) + return db.BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsSqlite ensures that a block exists in the database. If it // doesn't exist, it inserts it. func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - block *Block) error { + block *db.Block) error { height := int64(block.Height) @@ -47,7 +48,7 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // requireBlockMatchesSqlite loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - block *Block) (int64, error) { + block *db.Block) (int64, error) { height := int64(block.Height) @@ -55,7 +56,7 @@ func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("block %d: %w", block.Height, - ErrBlockNotFound) + db.ErrBlockNotFound) } return 0, fmt.Errorf("get block by height: %w", err) @@ -63,12 +64,12 @@ func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { return 0, fmt.Errorf("block %d header hash: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } if storedBlock.BlockTimestamp != block.Timestamp.Unix() { return 0, fmt.Errorf("block %d timestamp: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } return height, nil diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go new file mode 100644 index 0000000000..f5204d8dc4 --- /dev/null +++ b/wallet/internal/db/sqlite/itest.go @@ -0,0 +1,19 @@ +//go:build itest + +package sqlite + +import ( + "database/sql" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *SqliteStore) DB() *sql.DB { + return s.db +} + +// Queries returns the underlying sqlc queries for integration testing. +func (s *SqliteStore) Queries() *sqlcsqlite.Queries { + return s.queries +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite/store.go similarity index 73% rename from wallet/internal/db/sqlite.go rename to wallet/internal/db/sqlite/store.go index e12ec6dc13..a06ab5b188 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite/store.go @@ -1,15 +1,18 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) // SqliteStore is the SQLite implementation of the WalletStore interface. + type SqliteStore struct { db *sql.DB queries *sqlcsqlite.Queries @@ -19,7 +22,7 @@ type SqliteStore struct { // connection setup including DSN construction with pragmas, connection // opening, health checks, connection pool configuration, and migration // application. -func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, +func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, error) { err := cfg.Validate() @@ -33,39 +36,39 @@ func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, dsn += "&_pragma=busy_timeout=5000" dsn += "&_time_format=sqlite" - db, err := sql.Open("sqlite", dsn) + dbConn, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + connCtx, cancel := context.WithTimeout(ctx, db.DefaultConnectionTimeout) defer cancel() - err = db.PingContext(connCtx) + err = dbConn.PingContext(connCtx) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("ping database: %w", err) } - maxConns := DefaultMaxConnections + maxConns := db.DefaultMaxConnections if cfg.MaxConnections > 0 { maxConns = cfg.MaxConnections } - db.SetMaxOpenConns(maxConns) - db.SetMaxIdleConns(maxConns) - db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + dbConn.SetMaxOpenConns(maxConns) + dbConn.SetMaxIdleConns(maxConns) + dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcsqlite.New(db) + queries := sqlcsqlite.New(dbConn) - err = ApplySQLiteMigrations(db) + err = db.ApplySQLiteMigrations(dbConn) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) } return &SqliteStore{ - db: db, + db: dbConn, queries: queries, }, nil } @@ -87,7 +90,7 @@ func (s *SqliteStore) Close() error { func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { + return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go new file mode 100644 index 0000000000..9562137bc0 --- /dev/null +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -0,0 +1,163 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +var errDummy = errors.New("dummy") + +// errorDBTX forces sqlc exec/query calls down their wrapped error paths. +type errorDBTX struct { + execErr error + queryErr error +} + +// ExecContext implements the sqlc DBTX interface. +func (e errorDBTX) ExecContext(context.Context, string, + ...any) (sql.Result, error) { + + return nil, e.execErr +} + +// PrepareContext implements the sqlc DBTX interface. +func (e errorDBTX) PrepareContext(context.Context, + string) (*sql.Stmt, error) { + + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryContext(context.Context, string, + ...any) (*sql.Rows, error) { + + return nil, e.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryRowContext(context.Context, string, + ...any) *sql.Row { + + return &sql.Row{} +} + +// staticResult is a minimal sql.Result stub with a caller-controlled row count. +type staticResult struct { + rows int64 +} + +// LastInsertId implements sql.Result. +func (r staticResult) LastInsertId() (int64, error) { + return 0, nil +} + +// RowsAffected implements sql.Result. +func (r staticResult) RowsAffected() (int64, error) { + return r.rows, nil +} + +// rowDBTX is a sqlc DBTX stub that mixes fixed exec counts with query-row +// scan failures from a temporary sqlite handle. +type rowDBTX struct { + row *sql.Row + queryErr error + execErr error + rows int64 +} + +// ExecContext implements the sqlc DBTX interface. +func (r rowDBTX) ExecContext(context.Context, string, + ...any) (sql.Result, error) { + + if r.execErr != nil { + return nil, r.execErr + } + + return staticResult{rows: r.rows}, nil +} + +// PrepareContext implements the sqlc DBTX interface. +func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryContext(context.Context, string, + ...any) (*sql.Rows, error) { + + return nil, r.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryRowContext(context.Context, string, + ...any) *sql.Row { + + if r.row != nil { + return r.row + } + + return &sql.Row{} +} + +// newSQLiteRow creates a query row backed by an in-memory sqlite database. +func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { + t.Helper() + + dbConn, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { + _ = dbConn.Close() + }) + + return dbConn.QueryRowContext(t.Context(), query, args...) +} + +// testBlock builds one deterministic test block. +func testBlock(height uint32) *db.Block { + return &db.Block{ + Hash: chainhash.Hash{byte(height), 1, 2, 3}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + +// testRegularMsgTx builds one simple non-coinbase transaction fixture. +func testRegularMsgTx() *wire.MsgTx { + return &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{ + Value: int64(btcutil.SatoshiPerBitcoin), + PkScript: []byte{0x51}, + }}, + } +} + +// testCreateTxRequest builds one prepared CreateTx request for sqlite tests. +func testCreateTxRequest(t *testing.T) db.CreateTxRequest { + t.Helper() + + req, err := db.NewCreateTxRequest(db.CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + return req +} diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go similarity index 90% rename from wallet/internal/db/sqlite_txstore_createtx.go rename to wallet/internal/db/sqlite/txstore_createtx.go index 531c4579a7..a7ea3ab8b3 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" @@ -21,15 +22,15 @@ import ( // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, - params CreateTxParams) error { + params db.CreateTxParams) error { - req, err := NewCreateTxRequest(params) + req, err := db.NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ + return db.CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,11 +45,11 @@ type sqliteCreateTxOps struct { blockHeight sql.NullInt64 } -var _ CreateTxOps = (*sqliteCreateTxOps)(nil) +var _ db.CreateTxOps = (*sqliteCreateTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, - req CreateTxRequest) (*CreateTxExistingTarget, error) { + req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, @@ -59,18 +60,18 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrCreateTxExistingNotFound + return nil, db.ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(meta.TxStatus) + status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { return nil, err } - return &CreateTxExistingTarget{ + return &db.CreateTxExistingTarget{ ID: meta.ID, Status: status, HasBlock: meta.BlockHeight.Valid, @@ -80,8 +81,8 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, // ConfirmExisting promotes one existing unmined row to its confirmed state. func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, - req CreateTxRequest, - _ CreateTxExistingTarget) error { + req db.CreateTxRequest, + _ db.CreateTxExistingTarget) error { blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { @@ -91,7 +92,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, - Status: int64(TxStatusPublished), + Status: int64(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -101,7 +102,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, db.ErrTxNotFound) } return nil @@ -110,7 +111,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the sqlite // block-height value that the later Insert query will store. func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, - req CreateTxRequest) error { + req db.CreateTxRequest) error { o.blockHeight = sql.NullInt64{} @@ -131,7 +132,7 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, - req CreateTxRequest) ([]int64, []chainhash.Hash, error) { + req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -154,7 +155,7 @@ func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectSqliteConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, - req CreateTxRequest) (map[int64]struct{}, error) { + req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil @@ -216,7 +217,7 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, // Insert stores one new sqlite transaction row for CreateTx. func (o *sqliteCreateTxOps) Insert(ctx context.Context, - req CreateTxRequest) (int64, error) { + req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( ctx, @@ -232,7 +233,7 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, }, ) if err != nil { - return 0, fmt.Errorf("Insert tx row: %w", err) + return 0, fmt.Errorf("insert tx row: %w", err) } return txID, nil @@ -240,14 +241,14 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, // InsertCredits stores any wallet-owned outputs created by the transaction. func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) } @@ -260,7 +261,7 @@ func (o *sqliteCreateTxOps) MarkTxnsReplaced( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusReplaced), + Status: int64(db.TxStatusReplaced), TxIds: txIDs, }, ) @@ -286,7 +287,7 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("Insert replacement edge for %d: %w", + return fmt.Errorf("insert replacement edge for %d: %w", replacedTxID, err) } } @@ -297,7 +298,7 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( // insertCreditsSqlite inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { for index := range params.Credits { creditExists, err := creditExistsSqlite( @@ -322,7 +323,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("credit output %d: %w", index, - ErrAddressNotFound) + db.ErrAddressNotFound) } return fmt.Errorf("resolve credit address %d: %w", index, err) @@ -336,7 +337,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("Insert credit output %d: %w", index, err) + return fmt.Errorf("insert credit output %d: %w", index, err) } } @@ -376,7 +377,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { return nil @@ -437,7 +438,7 @@ func ensureSpendConflictSqlite(ctx context.Context, } if spendByTxID.Valid && spendByTxID.Int64 != txID { - return ErrTxInputConflict + return db.ErrTxInputConflict } return nil @@ -462,7 +463,7 @@ func ensureWalletParentValidSqlite(ctx context.Context, } if hasInvalid { - return ErrTxInputInvalidParent + return db.ErrTxInputInvalidParent } return nil diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go similarity index 89% rename from wallet/internal/db/sqlite_txstore_deletetx.go rename to wallet/internal/db/sqlite/txstore_deletetx.go index cbcc3969f0..8b90c3e080 100644 --- a/wallet/internal/db/sqlite_txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -18,10 +19,10 @@ import ( // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. func (s *SqliteStore) DeleteTx(ctx context.Context, - params DeleteTxParams) error { + params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) }) } @@ -30,7 +31,7 @@ type sqliteDeleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ DeleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*sqliteDeleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. @@ -121,7 +122,7 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := BuildUnminedTxRecords( + candidates, err := db.BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, []byte, []byte) { @@ -142,9 +143,9 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, filtered = append(filtered, candidate) } - if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(db.CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresLeaf) + db.ErrDeleteRequiresLeaf) } return nil @@ -165,22 +166,22 @@ func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return sqlcsqlite.GetTransactionMetaByHashRow{}, - fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(meta.TxStatus) + status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { return sqlcsqlite.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !IsUnminedStatus(status) { + if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresUnmined) + db.ErrDeleteRequiresUnmined) } return meta, nil diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go similarity index 81% rename from wallet/internal/db/sqlite_txstore_gettx.go rename to wallet/internal/db/sqlite/txstore_gettx.go index 63c3382248..b90fde980a 100644 --- a/wallet/internal/db/sqlite_txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -15,7 +16,7 @@ import ( // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. func (s *SqliteStore) GetTx(ctx context.Context, - query GetTxQuery) (*TxInfo, error) { + query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( ctx, sqlcsqlite.GetTransactionByHashParams{ @@ -25,7 +26,7 @@ func (s *SqliteStore) GetTx(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) } return nil, fmt.Errorf("get tx: %w", err) @@ -41,10 +42,10 @@ func (s *SqliteStore) GetTx(ctx context.Context, // TxInfo shape. func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, - status int64, label string) (*TxInfo, error) { + status int64, label string) (*db.TxInfo, error) { var ( - block *Block + block *db.Block err error ) @@ -57,5 +58,5 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, } } - return BuildTxInfo(hash, rawTx, received, block, status, label) + return db.BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go similarity index 77% rename from wallet/internal/db/sqlite_txstore_invalidateunmined.go rename to wallet/internal/db/sqlite/txstore_invalidateunmined.go index dcc229550b..659bc73951 100644 --- a/wallet/internal/db/sqlite_txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -13,10 +14,10 @@ import ( // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, - params InvalidateUnminedTxParams) error { + params db.InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return InvalidateUnminedTxWithOps( + return db.InvalidateUnminedTxWithOps( ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +29,13 @@ type sqliteInvalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { + walletID uint32, + txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ @@ -43,20 +45,21 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf( + "tx %s: %w", txHash, db.ErrTxNotFound, + ) } - return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(row.TxStatus) + status, err := db.ParseTxStatus(row.TxStatus) if err != nil { - return InvalidateUnminedTxTarget{}, err + return db.InvalidateUnminedTxTarget{}, err } - return InvalidateUnminedTxTarget{ + return db.InvalidateUnminedTxTarget{ ID: row.ID, TxHash: txHash, Status: status, @@ -68,14 +71,14 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords( + return db.BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -113,7 +116,7 @@ func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusFailed), + Status: int64(db.TxStatusFailed), TxIds: txIDs, }, ) diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go similarity index 87% rename from wallet/internal/db/sqlite_txstore_listtxns.go rename to wallet/internal/db/sqlite/txstore_listtxns.go index 0a92e1ac66..c332406f39 100644 --- a/wallet/internal/db/sqlite_txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -1,9 +1,10 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -15,7 +16,7 @@ import ( // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. func (s *SqliteStore) ListTxns(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) @@ -29,14 +30,14 @@ func (s *SqliteStore) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, - walletID uint32) ([]TxInfo, error) { + walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { info, err := txInfoFromSqliteRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, @@ -55,7 +56,7 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, // listConfirmedTxnsSqlite loads the confirmed height-range view used by // ListTxns when callers query mined history. func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ @@ -68,7 +69,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, fmt.Errorf("list txns by height: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { block, err := buildSqliteBlock( row.BlockHeight, @@ -79,7 +80,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, err } - info, err := BuildTxInfo( + info, err := db.BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, row.TxLabel, ) diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go similarity index 91% rename from wallet/internal/db/sqlite_txstore_rollback.go rename to wallet/internal/db/sqlite/txstore_rollback.go index 4bd62049e7..d68f224585 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -1,9 +1,10 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,7 +17,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return RollbackToBlockWithOps(ctx, height, + return db.RollbackToBlockWithOps(ctx, height, sqliteRollbackToBlockOps{qtx: qtx}) }) } @@ -27,7 +28,7 @@ type sqliteRollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. @@ -92,7 +93,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{}, - Status: int64(TxStatusOrphaned), + Status: int64(db.TxStatusOrphaned), WalletID: int64(walletID), TxHash: txHash[:], }, @@ -102,7 +103,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } } @@ -112,14 +113,14 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -157,7 +158,7 @@ func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusFailed), + Status: int64(db.TxStatusFailed), TxIds: descendantIDs, }, ) @@ -178,7 +179,7 @@ func groupRollbackCoinbaseRootsSqlite( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := Int64ToUint32(row.WalletID) + walletID, err := db.Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go similarity index 86% rename from wallet/internal/db/sqlite_txstore_updatetx.go rename to wallet/internal/db/sqlite/txstore_updatetx.go index c57d2104ca..dd8a13f183 100644 --- a/wallet/internal/db/sqlite_txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -17,10 +18,10 @@ import ( // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. func (s *SqliteStore) UpdateTx(ctx context.Context, - params UpdateTxParams) error { + params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) }) } @@ -38,7 +39,7 @@ type sqliteUpdateTxOps struct { status int64 } -var _ UpdateTxOps = (*sqliteUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*sqliteUpdateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. @@ -54,7 +55,7 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return false, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return false, fmt.Errorf("get tx metadata: %w", err) @@ -66,7 +67,7 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, // PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, - state UpdateTxState) error { + state db.UpdateTxState) error { blockHeight := sql.NullInt64{} @@ -102,7 +103,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil @@ -111,7 +112,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, - txHash chainhash.Hash, _ UpdateTxState) error { + txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( ctx, @@ -127,7 +128,7 @@ func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil diff --git a/wallet/internal/db/sqlite/utxo_extra_test.go b/wallet/internal/db/sqlite/utxo_extra_test.go new file mode 100644 index 0000000000..53ab180ff6 --- /dev/null +++ b/wallet/internal/db/sqlite/utxo_extra_test.go @@ -0,0 +1,34 @@ +package sqlite + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/stretchr/testify/require" +) + +// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies sqlite row decoding. +func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{13} + _, err := utxoInfoFromSqliteRow( + hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, + sql.NullInt64{}, + ) + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies sqlite row decoding. +func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{14} + _, err := utxoInfoFromSqliteRow( + hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, + sql.NullInt64{Int64: -1, Valid: true}, + ) + require.ErrorContains(t, err, "utxo block height") +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go similarity index 55% rename from wallet/internal/db/sqlite_utxostore_balance.go rename to wallet/internal/db/sqlite/utxostore_balance.go index b19c11f6df..d384f5b22a 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil/v2" @@ -11,23 +12,23 @@ import ( // Balance returns the sum of wallet-owned current UTXOs after optional filters. func (s *SqliteStore) Balance(ctx context.Context, - params BalanceParams) (BalanceResult, error) { + params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: NullableUint32ToSQLInt64(params.Account), - MinConfirms: NullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: NullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: NullableInt32ToSQLInt64(params.CoinbaseMaturity), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { - return BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, fmt.Errorf("balance: %w", err) } - return BalanceResult{ + return db.BalanceResult{ Total: btcutil.Amount(balance.TotalBalance), Locked: btcutil.Amount(balance.LockedBalance), }, nil diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_getutxo.go rename to wallet/internal/db/sqlite/utxostore_getutxo.go index c5efca3f26..30a3bf3adb 100644 --- a/wallet/internal/db/sqlite_utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -15,7 +16,7 @@ import ( // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. func (s *SqliteStore) GetUtxo(ctx context.Context, - query GetUtxoQuery) (*UtxoInfo, error) { + query db.GetUtxoQuery) (*db.UtxoInfo, error) { row, err := s.queries.GetUtxoByOutpoint( ctx, sqlcsqlite.GetUtxoByOutpointParams{ @@ -27,7 +28,7 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - ErrUtxoNotFound) + db.ErrUtxoNotFound) } return nil, fmt.Errorf("get utxo: %w", err) @@ -43,16 +44,16 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, // public UtxoInfo shape. func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, - blockHeight sql.NullInt64) (*UtxoInfo, error) { + blockHeight sql.NullInt64) (*db.UtxoInfo, error) { - index, err := Int64ToUint32(outputIndex) + index, err := db.Int64ToUint32(outputIndex) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := Int64ToUint32(blockHeight.Int64) + heightValue, err := db.Int64ToUint32(blockHeight.Int64) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -60,7 +61,7 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, height = &heightValue } - return BuildUtxoInfo( + return db.BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_leaseoutput.go rename to wallet/internal/db/sqlite/utxostore_leaseoutput.go index 202de6f4f9..2874ca98f7 100644 --- a/wallet/internal/db/sqlite_utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,12 +17,12 @@ import ( // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. func (s *SqliteStore) LeaseOutput(ctx context.Context, - params LeaseOutputParams) (*LeasedOutput, error) { + params db.LeaseOutputParams) (*db.LeasedOutput, error) { - var lease *LeasedOutput + var lease *db.LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - acquiredLease, err := LeaseOutputWithOps( + acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,12 +46,12 @@ type sqliteLeaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) // Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, - params LeaseOutputParams, nowUTC time.Time, + params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { expiration, err := o.qtx.AcquireUtxoLease( @@ -65,10 +66,10 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, ErrLeaseOutputNoRow + return time.Time{}, db.ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) } return expiration, nil @@ -77,7 +78,7 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, - params LeaseOutputParams) (bool, error) { + params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go similarity index 75% rename from wallet/internal/db/sqlite_utxostore_listleasedoutputs.go rename to wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 63c4000470..52d6a03f93 100644 --- a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -10,7 +11,7 @@ import ( // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, - walletID uint32) ([]LeasedOutput, error) { + walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() @@ -24,14 +25,14 @@ func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, return nil, fmt.Errorf("list active utxo leases: %w", err) } - leases := make([]LeasedOutput, len(rows)) + leases := make([]db.LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := Int64ToUint32(row.OutputIndex) + outputIndex, err := db.Int64ToUint32(row.OutputIndex) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := BuildLeasedOutput( + lease, err := db.BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go similarity index 70% rename from wallet/internal/db/sqlite_utxostore_listutxos.go rename to wallet/internal/db/sqlite/utxostore_listutxos.go index 20119197a6..1efb716c5b 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -12,19 +13,19 @@ import ( // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. func (s *SqliteStore) ListUTXOs(ctx context.Context, - query ListUtxosQuery) ([]UtxoInfo, error) { + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: NullableUint32ToSQLInt64(query.Account), - MinConfirms: NullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: NullableInt32ToSQLInt64(query.MaxConfs), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } - utxos := make([]UtxoInfo, len(rows)) + utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { utxo, err := utxoInfoFromSqliteRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_releaseoutput.go rename to wallet/internal/db/sqlite/utxostore_releaseoutput.go index 968ea65229..461fe4e1cb 100644 --- a/wallet/internal/db/sqlite_utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,10 +17,10 @@ import ( // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. func (s *SqliteStore) ReleaseOutput(ctx context.Context, - params ReleaseOutputParams) error { + params db.ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return ReleaseOutputWithOps( + return db.ReleaseOutputWithOps( ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, ) }) @@ -31,12 +32,12 @@ type sqliteReleaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, - params ReleaseOutputParams) (int64, error) { + params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ @@ -47,7 +48,7 @@ func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, ErrReleaseOutputUtxoNotFound + return 0, db.ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -69,7 +70,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("Release lease row: %w", err) + return 0, fmt.Errorf("release lease row: %w", err) } return rows, nil @@ -80,7 +81,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -89,11 +90,11 @@ func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrReleaseOutputNoActiveLease + return nil, db.ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return ActiveLockID, nil + return activeLockID, nil } diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/sqlite/wallet.go similarity index 89% rename from wallet/internal/db/wallet_sqlite.go rename to wallet/internal/db/sqlite/wallet.go index e0821b4b2f..f58cf5a500 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "context" @@ -7,20 +7,21 @@ import ( "fmt" "iter" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // Ensure SqliteStore satisfies the WalletStore interface. -var _ WalletStore = (*SqliteStore)(nil) +var _ db.WalletStore = (*SqliteStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. func (s *SqliteStore) CreateWallet(ctx context.Context, - params CreateWalletParams) (*WalletInfo, error) { + params db.CreateWalletParams) (*db.WalletInfo, error) { - var info *WalletInfo + var info *db.WalletInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ @@ -114,13 +115,13 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. func (s *SqliteStore) GetWallet(ctx context.Context, - name string) (*WalletInfo, error) { + name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, - ErrWalletNotFound) + db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet: %w", err) @@ -144,21 +145,21 @@ func (s *SqliteStore) GetWallet(ctx context.Context, // ListWallets returns a page of wallets matching the given query. func (s *SqliteStore) ListWallets(ctx context.Context, - query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { + query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( ctx, sqliteListWalletsParams(query.Page), ) if err != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) } - items := make([]WalletInfo, len(rows)) + items := make([]db.WalletInfo, len(rows)) for i, row := range rows { item, errMap := sqliteListWalletRowToInfo(row) if errMap != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) } @@ -167,7 +168,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, result := page.BuildResult( query.Page, items, - func(item WalletInfo) uint32 { + func(item db.WalletInfo) uint32 { return item.ID }, ) @@ -177,10 +178,10 @@ func (s *SqliteStore) ListWallets(ctx context.Context, // IterWallets returns an iterator over paginated wallet results. func (s *SqliteStore) IterWallets(ctx context.Context, - query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, NextListWalletsQuery, + ctx, query, s.ListWallets, db.NextListWalletsQuery, ) } @@ -189,7 +190,7 @@ func (s *SqliteStore) IterWallets(ctx context.Context, // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. func (s *SqliteStore) UpdateWallet(ctx context.Context, - params UpdateWalletParams) error { + params db.UpdateWalletParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. @@ -222,7 +223,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet sync state for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -240,7 +241,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, ErrWalletNotFound) + walletID, db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet secrets: %w", err) @@ -249,7 +250,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, if len(secrets.EncryptedMasterHdPrivKey) == 0 { return nil, fmt.Errorf( "encrypted master privkey for wallet %d: %w", walletID, - ErrSecretNotFound) + db.ErrSecretNotFound) } return secrets.EncryptedMasterHdPrivKey, nil @@ -257,7 +258,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, // UpdateWalletSecrets updates the secrets for the wallet. func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, - params UpdateWalletSecretsParams) error { + params db.UpdateWalletSecretsParams) error { secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, @@ -274,7 +275,7 @@ func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -300,7 +301,7 @@ type sqliteWalletRowParams struct { // sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. func sqliteListWalletRowToInfo( - row sqlcsqlite.ListWalletsRow) (*WalletInfo, error) { + row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { return buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, @@ -336,18 +337,18 @@ func sqliteListWalletsParams( // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. -func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { - walletID, err := Int64ToUint32(row.id) +func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { + walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err } - mgrVer, err := Int64ToInt32(row.managerVersion) + mgrVer, err := db.Int64ToInt32(row.managerVersion) if err != nil { return nil, err } - info := &WalletInfo{ + info := &db.WalletInfo{ ID: walletID, Name: row.name, IsImported: row.isImported, @@ -391,7 +392,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { // buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParamsSqlite( - params UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 3d51cd48b6..88854e8098 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -10,7 +10,6 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -84,37 +83,6 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "mark descendants failed") } -// TestSqliteDeleteAndRollbackOpsWrapBackendErrors verifies that the sqlite -// delete and rollback adapters preserve their step-specific error context when -// sqlc exec and query calls fail. -func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { - t.Parallel() - - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - deleteOps := sqliteDeleteTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - - err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear spent utxo rows") - - err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "delete created utxo rows") - - _, err = deleteOps.DeleteUnminedTransaction( - t.Context(), 1, chainhash.Hash{1}, - ) - require.ErrorContains(t, err, "delete unmined tx row") - - _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) - require.ErrorContains(t, err, "list unmined txns") - - err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear descendant spends") - - err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) - require.ErrorContains(t, err, "mark descendants failed") -} - // TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper // adapters preserve step-specific error context for create, invalidate, // rollback, update, and Release workflows. @@ -184,77 +152,6 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "Release lease row") } -// TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store -// helper adapters preserve step-specific error context for create, invalidate, -// rollback, update, and Release workflows. -func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { - t.Parallel() - - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: qtx, - }, - } - invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - updateOps := &sqliteUpdateTxOps{qtx: qtx} - releaseOps := sqliteReleaseOutputOps{qtx: qtx} - - err := createOps.MarkTxnsReplaced( - t.Context(), 1, []int64{2}, - ) - require.ErrorContains(t, err, "mark txns replaced") - - err = createOps.InsertReplacementEdges( - t.Context(), 1, []int64{2}, 3, - ) - require.ErrorContains(t, err, "Insert replacement edge") - - err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ - WalletID: 1, - Tx: testRegularMsgTx(), - Received: time.Unix(1, 0), - Status: TxStatusPending, - }, 7) - require.ErrorContains(t, err, "mark spent input 0") - - _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) - require.ErrorContains(t, err, "list unmined txns") - - err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear spent utxos") - - err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) - require.ErrorContains(t, err, "mark txns failed") - - _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) - require.ErrorContains(t, err, "query rollback coinbase roots") - - err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) - require.ErrorContains(t, err, "rewind wallet sync state heights query") - - err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) - require.ErrorContains(t, err, "delete blocks at or above height query") - - err = rollbackOps.MarkTxRootsOrphaned( - t.Context(), 1, []chainhash.Hash{{1}}, - ) - require.ErrorContains(t, err, "update rollback coinbase state query") - - updateOps.blockHeight = sql.NullInt64{} - updateOps.status = int64(TxStatusPublished) - err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, - UpdateTxState{Status: TxStatusPublished}) - require.ErrorContains(t, err, "update tx state query") - - err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") - require.ErrorContains(t, err, "update tx label query") - - _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "Release lease row") -} - // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper // branches that fail before issuing any SQL query. func TestPgBackendHelpersRejectOverflow(t *testing.T) { diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index 13be5a588f..70d1555ea2 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -7,9 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - "github.com/btcsuite/btcd/wire/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -138,91 +136,6 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { require.ErrorContains(t, err, "list unmined txns") } -// TestSqliteCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx -// helper branches that are hard to reach through public integration tests -// alone. -func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - req := testCreateTxRequest(t) - ctx := context.Background() - loadOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - }), - }, - } - - _, err := loadOps.LoadExisting(ctx, req) - require.ErrorContains(t, err, "get tx metadata") - - block := &Block{ - Hash: chainhash.Hash{4}, - Height: 8, - Timestamp: time.Unix(88, 0), - } - confirmOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow( - t, "SELECT ?, ?, ?", - int64(block.Height), block.Hash[:], - block.Timestamp.Unix(), - ), - rows: 0, - }), - }, - } - err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, - TxHash: chainhash.Hash{9}, - }, CreateTxExistingTarget{}) - require.ErrorIs(t, err, ErrTxNotFound) - - prepareOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - }), - }, - } - err = prepareOps.PrepareBlock(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, - }) - require.ErrorContains(t, err, "get block by height") - - conflictOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT ?", int64(5)), - queryErr: errDummy, - }), - }, - } - _, _, err = conflictOps.ListConflictTxns(ctx, req) - require.ErrorContains(t, err, "list unmined txns") -} - -// TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite -// Release-helper query-row error wrappers. -func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - })} - - _, err := ops.LookupUtxoID(context.Background(), ReleaseOutputParams{ - WalletID: 1, - OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, - }) - require.ErrorContains(t, err, "lookup utxo row") - - _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) - require.ErrorContains(t, err, "lookup active lease row") -} - // TestPgUpdateTxOpsAdditionalBranches covers the remaining postgres UpdateTx // helper branches that are hard to reach through public integration tests // alone. @@ -252,33 +165,3 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } - -// TestSqliteUpdateTxOpsAdditionalBranches covers the remaining sqlite UpdateTx -// helper branches that are hard to reach through public integration tests -// alone. -func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - ctx := context.Background() - txHash := chainhash.Hash{9} - loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - })} - stateOps := &sqliteUpdateTxOps{ - qtx: sqlcsqlite.New(rowDBTX{rows: 0}), - blockHeight: sql.NullInt64{}, - status: int64(TxStatusPublished), - } - labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} - - _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) - require.ErrorContains(t, err, "get tx metadata") - - err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ - Status: TxStatusPublished, - }) - require.ErrorIs(t, err, ErrTxNotFound) - - err = labelOps.UpdateLabel(ctx, 1, txHash, "note") - require.ErrorIs(t, err, ErrTxNotFound) -} From 015efd1b6685e10e3a3cebecec79a38e744085b5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:25:02 +0800 Subject: [PATCH 223/695] db/pg: move backend files Move the handwritten PostgreSQL store implementation into wallet/internal/db/pg while keeping shared contracts and helper logic in wallet/internal/db. This keeps the split mostly mechanical and updates the postgres-specific tests and itest helpers to import the new package. The move also shifts postgres-only backend tests out of the root db package so the root tests no longer depend on postgres-private symbols. --- wallet/internal/db/db_connectors_test.go | 5 +- wallet/internal/db/itest/fixtures_pg_test.go | 5 +- wallet/internal/db/itest/pg_test.go | 17 ++-- .../db/itest/tx_corruption_pg_test.go | 11 ++- .../db/itest/tx_store_corruption_test.go | 3 +- .../db/itest/utxo_store_edge_cases_test.go | 3 +- .../db/{accounts_pg.go => pg/accounts.go} | 99 ++++++++++--------- .../address_types.go} | 19 ++-- .../db/{addresses_pg.go => pg/addresses.go} | 81 +++++++-------- .../backend_error_test.go} | 27 ++--- .../backend_rows_test.go} | 23 ++--- .../internal/db/{block_pg.go => pg/block.go} | 23 ++--- .../internal/db/{db_itest.go => pg/itest.go} | 1 + wallet/internal/db/{pg.go => pg/store.go} | 32 +++--- wallet/internal/db/pg/testhelpers_test.go | 53 ++++++++++ .../txstore_createtx.go} | 67 ++++++------- .../txstore_deletetx.go} | 23 ++--- .../txstore_gettx.go} | 13 +-- .../txstore_invalidateunmined.go} | 29 +++--- .../txstore_listtxns.go} | 19 ++-- .../txstore_rollback.go} | 27 ++--- .../txstore_updatetx.go} | 19 ++-- wallet/internal/db/pg/utxo_extra_test.go | 34 +++++++ .../utxostore_balance.go} | 17 ++-- .../utxostore_getutxo.go} | 17 ++-- .../utxostore_leaseoutput.go} | 23 ++--- .../utxostore_listleasedoutputs.go} | 11 ++- .../utxostore_listutxos.go} | 15 +-- .../utxostore_releaseoutput.go} | 23 ++--- .../db/{wallet_pg.go => pg/wallet.go} | 53 +++++----- 30 files changed, 455 insertions(+), 337 deletions(-) rename wallet/internal/db/{accounts_pg.go => pg/accounts.go} (81%) rename wallet/internal/db/{address_types_pg.go => pg/address_types.go} (67%) rename wallet/internal/db/{addresses_pg.go => pg/addresses.go} (79%) rename wallet/internal/db/{tx_store_backend_error_test.go => pg/backend_error_test.go} (90%) rename wallet/internal/db/{tx_store_backend_rows_test.go => pg/backend_rows_test.go} (88%) rename wallet/internal/db/{block_pg.go => pg/block.go} (78%) rename wallet/internal/db/{db_itest.go => pg/itest.go} (88%) rename wallet/internal/db/{pg.go => pg/store.go} (71%) create mode 100644 wallet/internal/db/pg/testhelpers_test.go rename wallet/internal/db/{postgres_txstore_createtx.go => pg/txstore_createtx.go} (88%) rename wallet/internal/db/{postgres_txstore_deletetx.go => pg/txstore_deletetx.go} (89%) rename wallet/internal/db/{postgres_txstore_gettx.go => pg/txstore_gettx.go} (81%) rename wallet/internal/db/{postgres_txstore_invalidateunmined.go => pg/txstore_invalidateunmined.go} (77%) rename wallet/internal/db/{postgres_txstore_listtxns.go => pg/txstore_listtxns.go} (85%) rename wallet/internal/db/{postgres_txstore_rollback.go => pg/txstore_rollback.go} (89%) rename wallet/internal/db/{postgres_txstore_updatetx.go => pg/txstore_updatetx.go} (86%) create mode 100644 wallet/internal/db/pg/utxo_extra_test.go rename wallet/internal/db/{postgres_utxostore_balance.go => pg/utxostore_balance.go} (55%) rename wallet/internal/db/{postgres_utxostore_getutxo.go => pg/utxostore_getutxo.go} (79%) rename wallet/internal/db/{postgres_utxostore_leaseoutput.go => pg/utxostore_leaseoutput.go} (78%) rename wallet/internal/db/{postgres_utxostore_listleasedoutputs.go => pg/utxostore_listleasedoutputs.go} (74%) rename wallet/internal/db/{postgres_utxostore_listutxos.go => pg/utxostore_listutxos.go} (69%) rename wallet/internal/db/{postgres_utxostore_releaseoutput.go => pg/utxostore_releaseoutput.go} (80%) rename wallet/internal/db/{wallet_pg.go => pg/wallet.go} (89%) diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index a8b37a7d2d..f0c24649d2 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -5,6 +5,7 @@ import ( "testing" db "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := db.NewPostgresStore(t.Context(), tc.cfg) + store, err := dbpg.NewPostgresStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -53,7 +54,7 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { Dsn: "postgres://localhost:1/testdb", } - store, err := db.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewPostgresStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index b7d0a902f2..4b33892cee 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/stretchr/testify/require" ) @@ -189,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &db.PostgresStore{}, store) + require.IsType(t, &dbpg.PostgresStore{}, store) - pgStore := store.(*db.PostgresStore) + pgStore := store.(*dbpg.PostgresStore) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 6cc14accc2..dc013fb096 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" @@ -192,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *db.PostgresStore { +func NewTestStore(t *testing.T) *dbpg.PostgresStore { t.Helper() ctx := t.Context() @@ -227,7 +228,7 @@ func NewTestStore(t *testing.T) *db.PostgresStore { MaxConnections: 0, } - store, err := db.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewPostgresStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -239,7 +240,8 @@ func NewTestStore(t *testing.T) *db.PostgresStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, +func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, + walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() @@ -271,7 +273,7 @@ func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -295,7 +297,7 @@ func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -313,7 +315,7 @@ func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -334,7 +336,8 @@ func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *db.PostgresStore, walletID uint32, +func walletUtxoExists(t *testing.T, store *dbpg.PostgresStore, + walletID uint32, outPoint wire.OutPoint) bool { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index a1c0745138..8aa4b1c140 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -19,7 +20,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, +func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -49,7 +50,7 @@ func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *db.PostgresStore, +func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -75,7 +76,7 @@ func corruptTransactionHash(t *testing.T, store *db.PostgresStore, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -110,7 +111,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -136,7 +137,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *db.PostgresStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index f4958c89b9..d2f1bde364 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -21,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*db.PostgresStore); ok { + if _, ok := any(store).(*dbpg.PostgresStore); ok { stmt += " CASCADE" } diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index 76d28df269..c07ba93604 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -257,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*db.PostgresStore); ok { + if _, ok := any(store).(*dbpg.PostgresStore); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/pg/accounts.go similarity index 81% rename from wallet/internal/db/accounts_pg.go rename to wallet/internal/db/pg/accounts.go index 257be1d3e8..fb9bdbafb7 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/pg/accounts.go @@ -1,34 +1,35 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // Ensure PostgresStore satisfies the AccountStore interface. -var _ AccountStore = (*PostgresStore)(nil) +var _ db.AccountStore = (*PostgresStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. func (s *PostgresStore) GetAccount(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := pgAccountGetQueries{q: s.queries} - return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. func (s *PostgresStore) ListAccounts(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := pgAccountListQueries{q: s.queries} - return ListAccountsByQuery( + return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -36,11 +37,11 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. func (s *PostgresStore) RenameAccount(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { renameQueries := pgAccountRenameQueries{q: s.queries} - return RenameAccountByQuery( + return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -49,14 +50,14 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, - params CreateDerivedAccountParams) (*AccountInfo, error) { + params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } - var info *AccountInfo + var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { scopeID, err := pgEnsureKeyScope( @@ -80,7 +81,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, ctx, sqlcpg.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int16(DerivedAccount), + OriginID: int16(db.DerivedAccount), IsWatchOnly: false, }, ) @@ -91,16 +92,16 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return ErrNilDBAccountNumber + return db.ErrNilDBAccountNumber } - accNumber, err := Int64ToUint32(row.AccountNumber.Int64) + accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) } - info = BuildAccountInfo( - accNumber, params.Name, DerivedAccount, 0, 0, 0, false, + info = db.BuildAccountInfo( + accNumber, params.Name, db.DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -118,21 +119,21 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. func (s *PostgresStore) CreateImportedAccount(ctx context.Context, - params CreateImportedAccountParams) (*AccountProperties, error) { + params db.CreateImportedAccountParams) (*db.AccountProperties, error) { - var props *AccountProperties + var props *db.AccountProperties err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error - props, err = CreateImportedAccount( + props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, pgBuildCreateImportedAccountArgs(params), func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), - func(accountID int64) (*AccountProperties, error) { + func(accountID int64) (*db.AccountProperties, error) { return pgGetAccountProps(ctx, qtx, accountID) }, ) @@ -149,7 +150,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, // pgBuildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for PostgreSQL. func pgBuildCreateImportedAccountArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64, bool) sqlcpg.CreateImportedAccountParams { return func(scopeID int64, @@ -158,7 +159,7 @@ func pgBuildCreateImportedAccountArgs( return sqlcpg.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int16(ImportedAccount), + OriginID: int16(db.ImportedAccount), EncryptedPublicKey: params.EncryptedPublicKey, MasterFingerprint: sql.NullInt64{ Int64: int64(params.MasterFingerprint), @@ -172,7 +173,7 @@ func pgBuildCreateImportedAccountArgs( // pgBuildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for PostgreSQL. func pgBuildCreateAccountSecretArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64) sqlcpg.CreateAccountSecretParams { return func(accountID int64) sqlcpg.CreateAccountSecretParams { @@ -186,14 +187,14 @@ func pgBuildCreateAccountSecretArgs( // pgGetAccountProps fetches full account properties from the database and // converts the row to AccountProperties. func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, - accountID int64) (*AccountProperties, error) { + accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) if err != nil { return nil, fmt.Errorf("get account props: %w", err) } - return AccountPropsRowToProps(AccountPropsRow[int16, int16]{ + return db.AccountPropsRowToProps(db.AccountPropsRow[int16, int16]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -208,24 +209,24 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: IDToAddressType[int16], - IDToOriginType: IDToAccountOrigin[int16], + IDToAddrType: db.IDToAddressType[int16], + IDToOriginType: db.IDToAccountOrigin[int16], }) } // pgEnsureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, - scope KeyScope) (int64, error) { + scope db.KeyScope) (int64, error) { - return EnsureKeyScope( + return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcpg.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { + func(addrSchema db.ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { return sqlcpg.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), @@ -261,12 +262,12 @@ type pgAccountInfoRow interface { // pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo // struct. It uses type conversion since all pgAccountInfoRow types have // identical fields. -func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { +func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) - return AccountRowToInfo(AccountInfoRow[int16]{ + return db.AccountRowToInfo(db.AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -277,7 +278,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: IDToAccountOrigin[int16], + IDToOriginType: db.IDToAccountOrigin[int16], }) } @@ -288,9 +289,9 @@ type pgAccountListQueries struct { // byScope lists accounts filtered by wallet ID and key scope. func (p pgAccountListQueries) byScope(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWalletScope, sqlcpg.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -302,9 +303,9 @@ func (p pgAccountListQueries) byScope(ctx context.Context, // byName lists accounts filtered by wallet ID and account name. func (p pgAccountListQueries) byName(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWalletAndName, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -315,9 +316,9 @@ func (p pgAccountListQueries) byName(ctx context.Context, // all lists all accounts for a wallet. func (p pgAccountListQueries) all(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), pgAccountRowToInfo, ) @@ -330,24 +331,24 @@ type pgAccountGetQueries struct { // byNumber retrieves an account by wallet ID, scope, and account number. func (p pgAccountGetQueries) byNumber(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. func (p pgAccountGetQueries) byName(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, sqlcpg.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), @@ -366,16 +367,16 @@ type pgAccountRenameQueries struct { // byNumber renames an account identified by wallet ID, scope, and account // number. func (p pgAccountRenameQueries) byNumber(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -383,9 +384,9 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. func (p pgAccountRenameQueries) byName(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/pg/address_types.go similarity index 67% rename from wallet/internal/db/address_types_pg.go rename to wallet/internal/db/pg/address_types.go index f033ef9241..7aa90025f4 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/pg/address_types.go @@ -1,20 +1,21 @@ -package db +package pg import ( "context" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - addrType, err := IDToAddressType(row.ID) +func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { + addrType, err := db.IDToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, err + return db.AddressTypeInfo{}, err } - return AddressTypeInfo{ + return db.AddressTypeInfo{ Type: addrType, Description: row.Description, }, nil @@ -23,9 +24,9 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( - []AddressTypeInfo, error) { + []db.AddressTypeInfo, error) { - return ListAddressTypes( + return db.ListAddressTypes( ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } @@ -33,9 +34,9 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. func (s *PostgresStore) GetAddressType(ctx context.Context, - id AddressType) (AddressTypeInfo, error) { + id db.AddressType) (db.AddressTypeInfo, error) { - return GetAddressTypeByID( + return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/pg/addresses.go similarity index 79% rename from wallet/internal/db/addresses_pg.go rename to wallet/internal/db/pg/addresses.go index 8aba77a7a1..41f56871d2 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/pg/addresses.go @@ -1,4 +1,4 @@ -package db +package pg import ( "context" @@ -7,21 +7,22 @@ import ( "iter" "time" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -var _ AddressStore = (*PostgresStore)(nil) +var _ db.AddressStore = (*PostgresStore)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *PostgresStore) GetAddress(ctx context.Context, - query GetAddressQuery) (*AddressInfo, error) { + query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + getByScript := func(ctx context.Context, q db.GetAddressQuery) (*db.AddressInfo, error) { - return GetAddress( + return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, @@ -30,21 +31,21 @@ func (s *PostgresStore) GetAddress(ctx context.Context, ) } - return GetAddressByQuery(ctx, query, getByScript) + return db.GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. func (s *PostgresStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := pgListAddressesByAccount(ctx, s.queries, query) if err != nil { - return page.Result[AddressInfo, uint32]{}, err + return page.Result[db.AddressInfo, uint32]{}, err } result := page.BuildResult( query.Page, items, - func(item AddressInfo) uint32 { + func(item db.AddressInfo) uint32 { return item.ID }, ) @@ -54,18 +55,18 @@ func (s *PostgresStore) ListAddresses(ctx context.Context, // IterAddresses returns an iterator over paginated address results. func (s *PostgresStore) IterAddresses(ctx context.Context, - query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, NextListAddressesQuery, + ctx, query, s.ListAddresses, db.NextListAddressesQuery, ) } // GetAddressSecret retrieves the encrypted secret information for an address. func (s *PostgresStore) GetAddressSecret(ctx context.Context, - addressID uint32) (*AddressSecret, error) { + addressID uint32) (*db.AddressSecret, error) { - return GetAddressSecret( + return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } @@ -73,16 +74,16 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *PostgresStore) NewDerivedAddress(ctx context.Context, - params NewDerivedAddressParams, - deriveFn AddressDerivationFunc) (*AddressInfo, error) { + params db.NewDerivedAddressParams, + deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { - adapters := DerivedAddressAdapters[ + adapters := db.DerivedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ GetAccount: pgGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromParams, + AccountParams: db.AccountKeyFromParams, GetAccountID: newDerivedAddressGetAccountIDPg, GetExtIndex: newDerivedAddressGetExtIndexPg, GetIntIndex: newDerivedAddressGetIntIndexPg, @@ -91,22 +92,22 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *PostgresStore) NewImportedAddress(ctx context.Context, - params NewImportedAddressParams) (*AddressInfo, error) { + params db.NewImportedAddressParams) (*db.AddressInfo, error) { - adapters := ImportedAddressAdapters[ + adapters := db.ImportedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ GetAccount: pgGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromImportedParams, + AccountParams: db.AccountKeyFromImportedParams, GetAccountID: newImportedAddressGetAccountIDPg, CreateAddr: pgCreateImportedAddress, CreateParams: createImportedAddressParamsPg, @@ -116,15 +117,15 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAtPg, } - return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( @@ -161,14 +162,14 @@ func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, // newDerivedAddressCreateAddrPg returns the derived address insert helper. func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, - int64, AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, + int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, error) { - return func(ctx context.Context, accountID int64, addrType AddressType, + return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { - branchNum, err := Uint32ToInt16(branch) + branchNum, err := db.Uint32ToInt16(branch) if err != nil { return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, @@ -230,7 +231,7 @@ func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, // createImportedAddressParamsPg maps imported params to sqlc params. func createImportedAddressParamsPg(accountID int64, - params NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { return sqlcpg.CreateImportedAddressParams{ AccountID: accountID, @@ -242,7 +243,7 @@ func createImportedAddressParamsPg(accountID int64, // insertAddressSecretParamsPg maps imported params to secret params. func insertAddressSecretParamsPg(addressID int64, - params NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { return sqlcpg.InsertAddressSecretParams{ AddressID: addressID, @@ -266,9 +267,9 @@ func importedAddressRowCreatedAtPg( // pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. func pgAddressSecretRowToSecret( - row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { + row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { - return AddressSecretRowToSecret(AddressSecretRow{ + return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -285,12 +286,12 @@ type pgAddressInfoRow interface { // pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo // struct. -func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { +func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := AddressRowToInfo(AddressInfoRow[int16, int16]{ + info, err := db.AddressRowToInfo(db.AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -305,8 +306,8 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: IDToAddressType[int16], - IDToOrigin: IDToOrigin[int16], + IDToAddrType: db.IDToAddressType[int16], + IDToOrigin: db.IDToOrigin[int16], }) if err != nil { return nil, err @@ -318,7 +319,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, - query ListAddressesQuery) ([]AddressInfo, error) { + query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( ctx, pgBuildAddressPageParams(query), @@ -327,7 +328,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, return nil, fmt.Errorf("list addresses by account: %w", err) } - items := make([]AddressInfo, len(rows)) + items := make([]db.AddressInfo, len(rows)) for i, row := range rows { item, err := pgAddressRowToInfo(row) if err != nil { @@ -345,7 +346,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, // pgBuildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func pgBuildAddressPageParams( - q ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { params := sqlcpg.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go similarity index 90% rename from wallet/internal/db/tx_store_backend_error_test.go rename to wallet/internal/db/pg/backend_error_test.go index 88854e8098..63dfc0860c 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "errors" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" @@ -106,13 +107,13 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "Insert replacement edge") + require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ + err = markInputsSpentPg(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), - Status: TxStatusPending, + Status: db.TxStatusPending, }, 7) require.ErrorContains(t, err, "mark spent input 0") @@ -140,16 +141,16 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt32{} - updateOps.status = int16(TxStatusPublished) + updateOps.status = int16(db.TxStatusPublished) err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, - UpdateTxState{Status: TxStatusPublished}) + db.UpdateTxState{Status: db.TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "Release lease row") + require.ErrorContains(t, err, "release lease row") } // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper @@ -157,7 +158,7 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { func TestPgBackendHelpersRejectOverflow(t *testing.T) { t.Parallel() - req, err := NewCreateTxRequest(CreateTxParams{ + req, err := db.NewCreateTxRequest(db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -170,7 +171,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, }, Received: time.Unix(1, 0), - Status: TxStatusPending, + Status: db.TxStatusPending, }) require.NoError(t, err) @@ -182,7 +183,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) require.ErrorContains(t, err, "convert credit index") - err = markInputsSpentPg(t.Context(), nil, CreateTxParams{ + err = markInputsSpentPg(t.Context(), nil, db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -193,7 +194,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, }}, }, - Status: TxStatusPending, + Status: db.TxStatusPending, }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") @@ -216,14 +217,14 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { leaseOps := &pgLeaseOutputOps{} - _, err = leaseOps.Acquire(t.Context(), LeaseOutputParams{ + _, err = leaseOps.Acquire(t.Context(), db.LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = leaseOps.HasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.HasUtxo(t.Context(), db.LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go similarity index 88% rename from wallet/internal/db/tx_store_backend_rows_test.go rename to wallet/internal/db/pg/backend_rows_test.go index 70d1555ea2..4d04b784b8 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "database/sql" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" @@ -101,7 +102,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") - block := &Block{ + block := &db.Block{ Hash: chainhash.Hash{3}, Height: 7, Timestamp: time.Unix(77, 0), @@ -118,11 +119,11 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, + err = confirmOps.ConfirmExisting(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, TxHash: chainhash.Hash{9}, - }, CreateTxExistingTarget{}) - require.ErrorIs(t, err, ErrTxNotFound) + }, db.CreateTxExistingTarget{}) + require.ErrorIs(t, err, db.ErrTxNotFound) conflictOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ @@ -150,18 +151,18 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { stateOps := &pgUpdateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, - status: int16(TxStatusPublished), + status: int16(db.TxStatusPublished), } labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ - Status: TxStatusPublished, + err = stateOps.UpdateState(ctx, 1, txHash, db.UpdateTxState{ + Status: db.TxStatusPublished, }) - require.ErrorIs(t, err, ErrTxNotFound) + require.ErrorIs(t, err, db.ErrTxNotFound) err = labelOps.UpdateLabel(ctx, 1, txHash, "note") - require.ErrorIs(t, err, ErrTxNotFound) + require.ErrorIs(t, err, db.ErrTxNotFound) } diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/pg/block.go similarity index 78% rename from wallet/internal/db/block_pg.go rename to wallet/internal/db/pg/block.go index b25aaf0172..fc8559d2f1 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/pg/block.go @@ -1,4 +1,4 @@ -package db +package pg import ( "bytes" @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -13,21 +14,21 @@ import ( // buildPgBlock constructs a Block from the given PostgreSQL block // fields. func buildPgBlock(height sql.NullInt32, hash []byte, - timestamp sql.NullInt64) (*Block, error) { + timestamp sql.NullInt64) (*db.Block, error) { - height32, err := NullInt32ToUint32(height) + height32, err := db.NullInt32ToUint32(height) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return BuildBlock(hash, height32, timestamp.Int64) + return db.BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsPg ensures that a block exists in the database. func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, - block *Block) error { + block *db.Block) error { - height, err := Uint32ToInt32(block.Height) + height, err := db.Uint32ToInt32(block.Height) if err != nil { return fmt.Errorf("convert block height: %w", err) } @@ -49,9 +50,9 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // requireBlockMatchesPg loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, - block *Block) (int32, error) { + block *db.Block) (int32, error) { - height, err := Uint32ToInt32(block.Height) + height, err := db.Uint32ToInt32(block.Height) if err != nil { return 0, fmt.Errorf("convert block height: %w", err) } @@ -60,7 +61,7 @@ func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("block %d: %w", block.Height, - ErrBlockNotFound) + db.ErrBlockNotFound) } return 0, fmt.Errorf("get block by height: %w", err) @@ -68,12 +69,12 @@ func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { return 0, fmt.Errorf("block %d header hash: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } if storedBlock.BlockTimestamp != block.Timestamp.Unix() { return 0, fmt.Errorf("block %d timestamp: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } return height, nil diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/pg/itest.go similarity index 88% rename from wallet/internal/db/db_itest.go rename to wallet/internal/db/pg/itest.go index fb53c7271a..89dc9d4d87 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/pg/itest.go @@ -4,6 +4,7 @@ package db import ( "database/sql" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg/store.go similarity index 71% rename from wallet/internal/db/pg.go rename to wallet/internal/db/pg/store.go index 2441ba1265..c351bd45f8 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg/store.go @@ -1,10 +1,12 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -19,7 +21,7 @@ type PostgresStore struct { // NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles // the full connection setup including config validation, connection opening, // health checks, connection pool configuration, and migration application. -func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, +func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStore, error) { err := cfg.Validate() @@ -27,39 +29,39 @@ func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, return nil, fmt.Errorf("invalid config: %w", err) } - db, err := sql.Open("pgx", cfg.Dsn) + dbConn, err := sql.Open("pgx", cfg.Dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + connCtx, cancel := context.WithTimeout(ctx, db.DefaultConnectionTimeout) defer cancel() - err = db.PingContext(connCtx) + err = dbConn.PingContext(connCtx) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("ping database: %w", err) } - maxConns := DefaultMaxConnections + maxConns := db.DefaultMaxConnections if cfg.MaxConnections > 0 { maxConns = cfg.MaxConnections } - db.SetMaxOpenConns(maxConns) - db.SetMaxIdleConns(maxConns) - db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + dbConn.SetMaxOpenConns(maxConns) + dbConn.SetMaxIdleConns(maxConns) + dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcpg.New(db) + queries := sqlcpg.New(dbConn) - err = ApplyPostgresMigrations(db) + err = db.ApplyPostgresMigrations(dbConn) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) } return &PostgresStore{ - db: db, + db: dbConn, queries: queries, }, nil } @@ -81,7 +83,7 @@ func (s *PostgresStore) Close() error { func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { + return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go new file mode 100644 index 0000000000..9429524854 --- /dev/null +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -0,0 +1,53 @@ +package pg + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// testBlock builds one deterministic test block. +func testBlock(height uint32) *db.Block { + return &db.Block{ + Hash: chainhash.Hash{byte(height), 1, 2, 3}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + +// testRegularMsgTx builds one simple non-coinbase transaction fixture. +func testRegularMsgTx() *wire.MsgTx { + return &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{ + Value: int64(btcutil.SatoshiPerBitcoin), + PkScript: []byte{0x51}, + }}, + } +} + +// testCreateTxRequest builds one prepared CreateTx request for pg tests. +func testCreateTxRequest(t *testing.T) db.CreateTxRequest { + t.Helper() + + req, err := db.NewCreateTxRequest(db.CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + return req +} diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go similarity index 88% rename from wallet/internal/db/postgres_txstore_createtx.go rename to wallet/internal/db/pg/txstore_createtx.go index dee3a8bfb7..ebd624d3b9 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" @@ -21,15 +22,15 @@ import ( // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, - params CreateTxParams) error { + params db.CreateTxParams) error { - req, err := NewCreateTxRequest(params) + req, err := db.NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return CreateTxWithOps(ctx, req, &pgCreateTxOps{ + return db.CreateTxWithOps(ctx, req, &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,11 +45,11 @@ type pgCreateTxOps struct { blockHeight sql.NullInt32 } -var _ CreateTxOps = (*pgCreateTxOps)(nil) +var _ db.CreateTxOps = (*pgCreateTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. func (o *pgCreateTxOps) LoadExisting(ctx context.Context, - req CreateTxRequest) (*CreateTxExistingTarget, error) { + req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, @@ -59,18 +60,18 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrCreateTxExistingNotFound + return nil, db.ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(meta.TxStatus)) + status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { return nil, err } - return &CreateTxExistingTarget{ + return &db.CreateTxExistingTarget{ ID: meta.ID, Status: status, HasBlock: meta.BlockHeight.Valid, @@ -80,8 +81,8 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, // ConfirmExisting promotes one existing unmined row to its confirmed state. func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, - req CreateTxRequest, - _ CreateTxExistingTarget) error { + req db.CreateTxRequest, + _ db.CreateTxExistingTarget) error { blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { @@ -91,7 +92,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, - Status: int16(TxStatusPublished), + Status: int16(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -101,7 +102,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, db.ErrTxNotFound) } return nil @@ -110,7 +111,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the postgres // block-height value that the later Insert query will store. func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, - req CreateTxRequest) error { + req db.CreateTxRequest) error { o.blockHeight = sql.NullInt32{} @@ -131,7 +132,7 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, - req CreateTxRequest) ([]int64, []chainhash.Hash, error) { + req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -153,7 +154,7 @@ func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, // collectPgConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, - req CreateTxRequest) (map[int64]struct{}, error) { + req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil @@ -161,7 +162,7 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) for inputIndex, txIn := range req.Params.Tx.TxIn { - outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := db.Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return nil, fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) @@ -221,7 +222,7 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, // Insert stores one new postgres transaction row for CreateTx. func (o *pgCreateTxOps) Insert(ctx context.Context, - req CreateTxRequest) (int64, error) { + req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), @@ -234,7 +235,7 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, TxLabel: req.Params.Label, }) if err != nil { - return 0, fmt.Errorf("Insert tx row: %w", err) + return 0, fmt.Errorf("insert tx row: %w", err) } return txID, nil @@ -242,14 +243,14 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, // InsertCredits stores any wallet-owned outputs created by the transaction. func (o *pgCreateTxOps) InsertCredits(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return insertCreditsPg(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return markInputsSpentPg(ctx, o.qtx, req.Params, txID) } @@ -262,7 +263,7 @@ func (o *pgCreateTxOps) MarkTxnsReplaced( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusReplaced), + Status: int16(db.TxStatusReplaced), TxIds: txIDs, }, ) @@ -288,7 +289,7 @@ func (o *pgCreateTxOps) InsertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("Insert replacement edge for %d: %w", + return fmt.Errorf("insert replacement edge for %d: %w", replacedTxID, err) } } @@ -299,7 +300,7 @@ func (o *pgCreateTxOps) InsertReplacementEdges( // insertCreditsPg inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { for index := range params.Credits { creditExists, err := creditExistsPg( @@ -324,13 +325,13 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("credit output %d: %w", index, - ErrAddressNotFound) + db.ErrAddressNotFound) } return fmt.Errorf("resolve credit address %d: %w", index, err) } - outputIndex, err := Uint32ToInt32(index) + outputIndex, err := db.Uint32ToInt32(index) if err != nil { return fmt.Errorf("convert credit index %d: %w", index, err) } @@ -343,7 +344,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("Insert credit output %d: %w", index, err) + return fmt.Errorf("insert credit output %d: %w", index, err) } } @@ -355,7 +356,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { - convertedIndex, err := Uint32ToInt32(outputIndex) + convertedIndex, err := db.Uint32ToInt32(outputIndex) if err != nil { return false, fmt.Errorf("convert credit index %d: %w", outputIndex, err) @@ -389,20 +390,20 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { return nil } for inputIndex, txIn := range params.Tx.TxIn { - outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := db.Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) } - spentInputIndex, err := Int64ToInt32(int64(inputIndex)) + spentInputIndex, err := db.Int64ToInt32(int64(inputIndex)) if err != nil { return fmt.Errorf("convert input index %d: %w", inputIndex, err) } @@ -458,7 +459,7 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, } if spendByTxID.Valid && spendByTxID.Int64 != txID { - return ErrTxInputConflict + return db.ErrTxInputConflict } return nil @@ -481,7 +482,7 @@ func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, } if hasInvalid { - return ErrTxInputInvalidParent + return db.ErrTxInputInvalidParent } return nil diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go similarity index 89% rename from wallet/internal/db/postgres_txstore_deletetx.go rename to wallet/internal/db/pg/txstore_deletetx.go index c9182d2eb8..97bd27c969 100644 --- a/wallet/internal/db/postgres_txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -18,10 +19,10 @@ import ( // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. func (s *PostgresStore) DeleteTx(ctx context.Context, - params DeleteTxParams) error { + params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) }) } @@ -30,7 +31,7 @@ type pgDeleteTxOps struct { qtx *sqlcpg.Queries } -var _ DeleteTxOps = (*pgDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*pgDeleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. @@ -121,7 +122,7 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := BuildUnminedTxRecords(rows, + candidates, err := db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -139,9 +140,9 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, filtered = append(filtered, candidate) } - if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(db.CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresLeaf) + db.ErrDeleteRequiresLeaf) } return nil @@ -162,22 +163,22 @@ func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return sqlcpg.GetTransactionMetaByHashRow{}, - fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(meta.TxStatus)) + status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { return sqlcpg.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !IsUnminedStatus(status) { + if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresUnmined) + db.ErrDeleteRequiresUnmined) } return meta, nil diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go similarity index 81% rename from wallet/internal/db/postgres_txstore_gettx.go rename to wallet/internal/db/pg/txstore_gettx.go index 4e154e0ad8..8e1bbac1bf 100644 --- a/wallet/internal/db/postgres_txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -15,7 +16,7 @@ import ( // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. func (s *PostgresStore) GetTx(ctx context.Context, - query GetTxQuery) (*TxInfo, error) { + query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( ctx, sqlcpg.GetTransactionByHashParams{ @@ -25,7 +26,7 @@ func (s *PostgresStore) GetTx(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) } return nil, fmt.Errorf("get tx: %w", err) @@ -41,10 +42,10 @@ func (s *PostgresStore) GetTx(ctx context.Context, // TxInfo shape. func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, - status int64, label string) (*TxInfo, error) { + status int64, label string) (*db.TxInfo, error) { var ( - block *Block + block *db.Block err error ) @@ -57,5 +58,5 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, } } - return BuildTxInfo(hash, rawTx, received, block, status, label) + return db.BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go similarity index 77% rename from wallet/internal/db/postgres_txstore_invalidateunmined.go rename to wallet/internal/db/pg/txstore_invalidateunmined.go index 49b007e88e..585ad519f5 100644 --- a/wallet/internal/db/postgres_txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -13,10 +14,10 @@ import ( // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, - params InvalidateUnminedTxParams) error { + params db.InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return InvalidateUnminedTxWithOps( + return db.InvalidateUnminedTxWithOps( ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +29,12 @@ type pgInvalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { + walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ @@ -43,20 +44,20 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + db.ErrTxNotFound) } - return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(row.TxStatus)) + status, err := db.ParseTxStatus(int64(row.TxStatus)) if err != nil { - return InvalidateUnminedTxTarget{}, err + return db.InvalidateUnminedTxTarget{}, err } - return InvalidateUnminedTxTarget{ + return db.InvalidateUnminedTxTarget{ ID: row.ID, TxHash: txHash, Status: status, @@ -68,14 +69,14 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -111,7 +112,7 @@ func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusFailed), + Status: int16(db.TxStatusFailed), TxIds: txIDs, }, ) diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go similarity index 85% rename from wallet/internal/db/postgres_txstore_listtxns.go rename to wallet/internal/db/pg/txstore_listtxns.go index 36ed4ed816..8438214d0e 100644 --- a/wallet/internal/db/postgres_txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -15,7 +16,7 @@ import ( // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. func (s *PostgresStore) ListTxns(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { return s.listTxnsWithoutBlockPg(ctx, query.WalletID) @@ -29,14 +30,14 @@ func (s *PostgresStore) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, - walletID uint32) ([]TxInfo, error) { + walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { info, err := txInfoFromPgRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, @@ -55,14 +56,14 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, // listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns // when callers query mined history. func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { - startHeight, err := Uint32ToInt32(query.StartHeight) + startHeight, err := db.Uint32ToInt32(query.StartHeight) if err != nil { return nil, fmt.Errorf("convert start height: %w", err) } - endHeight, err := Uint32ToInt32(query.EndHeight) + endHeight, err := db.Uint32ToInt32(query.EndHeight) if err != nil { return nil, fmt.Errorf("convert end height: %w", err) } @@ -78,7 +79,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, fmt.Errorf("list txns by height: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { block, err := buildPgBlock( row.BlockHeight, @@ -89,7 +90,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, err } - info, err := BuildTxInfo( + info, err := db.BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, int64(row.TxStatus), row.TxLabel, ) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go similarity index 89% rename from wallet/internal/db/postgres_txstore_rollback.go rename to wallet/internal/db/pg/txstore_rollback.go index dec0984047..fcd5dfb798 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,7 +17,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return RollbackToBlockWithOps(ctx, height, + return db.RollbackToBlockWithOps(ctx, height, pgRollbackToBlockOps{qtx: qtx}) }) } @@ -27,14 +28,14 @@ type pgRollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return nil, fmt.Errorf("convert rollback height: %w", err) } @@ -60,14 +61,14 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( // // TODO(yy): Fix it when we are in year 42000, which will give us 800 years // before it's reached. - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } newHeight := sql.NullInt32{} if height > 0 { - newHeight, err = Uint32ToNullInt32(height - 1) + newHeight, err = db.Uint32ToNullInt32(height - 1) if err != nil { return fmt.Errorf("convert new height: %w", err) } @@ -91,7 +92,7 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } @@ -117,7 +118,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{}, - Status: int16(TxStatusOrphaned), + Status: int16(db.TxStatusOrphaned), WalletID: int64(walletID), TxHash: txHash[:], }, @@ -127,7 +128,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } } @@ -137,14 +138,14 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o pgRollbackToBlockOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -180,7 +181,7 @@ func (o pgRollbackToBlockOps) MarkDescendantsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusFailed), + Status: int16(db.TxStatusFailed), TxIds: descendantIDs, }, ) @@ -200,7 +201,7 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := Int64ToUint32(row.WalletID) + walletID, err := db.Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go similarity index 86% rename from wallet/internal/db/postgres_txstore_updatetx.go rename to wallet/internal/db/pg/txstore_updatetx.go index 81d4905686..7103ee27a2 100644 --- a/wallet/internal/db/postgres_txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -17,10 +18,10 @@ import ( // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. func (s *PostgresStore) UpdateTx(ctx context.Context, - params UpdateTxParams) error { + params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) }) } @@ -38,7 +39,7 @@ type pgUpdateTxOps struct { status int16 } -var _ UpdateTxOps = (*pgUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*pgUpdateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. @@ -54,7 +55,7 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return false, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return false, fmt.Errorf("get tx metadata: %w", err) @@ -66,7 +67,7 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, // PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. func (o *pgUpdateTxOps) PrepareState(ctx context.Context, - state UpdateTxState) error { + state db.UpdateTxState) error { blockHeight := sql.NullInt32{} @@ -88,7 +89,7 @@ func (o *pgUpdateTxOps) PrepareState(ctx context.Context, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, - txHash chainhash.Hash, _ UpdateTxState) error { + txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( ctx, @@ -104,7 +105,7 @@ func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil @@ -127,7 +128,7 @@ func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil diff --git a/wallet/internal/db/pg/utxo_extra_test.go b/wallet/internal/db/pg/utxo_extra_test.go new file mode 100644 index 0000000000..959cca6f7a --- /dev/null +++ b/wallet/internal/db/pg/utxo_extra_test.go @@ -0,0 +1,34 @@ +package pg + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/stretchr/testify/require" +) + +// TestUtxoInfoFromPgRowInvalidOutputIndex verifies postgres row decoding. +func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{15} + _, err := utxoInfoFromPgRow( + hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, + sql.NullInt32{}, + ) + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromPgRowInvalidBlockHeight verifies postgres row decoding. +func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{16} + _, err := utxoInfoFromPgRow( + hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, + sql.NullInt32{Int32: -1, Valid: true}, + ) + require.ErrorContains(t, err, "utxo block height") +} diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go similarity index 55% rename from wallet/internal/db/postgres_utxostore_balance.go rename to wallet/internal/db/pg/utxostore_balance.go index 05d88fd37b..a5cf022c2b 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil/v2" @@ -11,23 +12,23 @@ import ( // Balance returns the sum of wallet-owned current UTXOs after optional filters. func (s *PostgresStore) Balance(ctx context.Context, - params BalanceParams) (BalanceResult, error) { + params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: NullableUint32ToSQLInt64(params.Account), - MinConfirms: NullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: NullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: NullableInt32ToSQLInt32(params.CoinbaseMaturity), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { - return BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, fmt.Errorf("balance: %w", err) } - return BalanceResult{ + return db.BalanceResult{ Total: btcutil.Amount(balance.TotalBalance), Locked: btcutil.Amount(balance.LockedBalance), }, nil diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go similarity index 79% rename from wallet/internal/db/postgres_utxostore_getutxo.go rename to wallet/internal/db/pg/utxostore_getutxo.go index b55ed91759..9e58067f85 100644 --- a/wallet/internal/db/postgres_utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -15,9 +16,9 @@ import ( // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. func (s *PostgresStore) GetUtxo(ctx context.Context, - query GetUtxoQuery) (*UtxoInfo, error) { + query db.GetUtxoQuery) (*db.UtxoInfo, error) { - outputIndex, err := Uint32ToInt32(query.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(query.OutPoint.Index) if err != nil { return nil, fmt.Errorf("convert output index: %w", err) } @@ -32,7 +33,7 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - ErrUtxoNotFound) + db.ErrUtxoNotFound) } return nil, fmt.Errorf("get utxo: %w", err) @@ -48,16 +49,16 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, // UtxoInfo shape. func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, - blockHeight sql.NullInt32) (*UtxoInfo, error) { + blockHeight sql.NullInt32) (*db.UtxoInfo, error) { - index, err := Int64ToUint32(int64(outputIndex)) + index, err := db.Int64ToUint32(int64(outputIndex)) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := NullInt32ToUint32(blockHeight) + heightValue, err := db.NullInt32ToUint32(blockHeight) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -65,7 +66,7 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, height = &heightValue } - return BuildUtxoInfo( + return db.BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go similarity index 78% rename from wallet/internal/db/postgres_utxostore_leaseoutput.go rename to wallet/internal/db/pg/utxostore_leaseoutput.go index 05e0c7f624..9f17905edf 100644 --- a/wallet/internal/db/postgres_utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,12 +17,12 @@ import ( // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. func (s *PostgresStore) LeaseOutput(ctx context.Context, - params LeaseOutputParams) (*LeasedOutput, error) { + params db.LeaseOutputParams) (*db.LeasedOutput, error) { - var lease *LeasedOutput + var lease *db.LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - acquiredLease, err := LeaseOutputWithOps( + acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &pgLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,15 +46,15 @@ type pgLeaseOutputOps struct { qtx *sqlcpg.Queries } -var _ LeaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*pgLeaseOutputOps)(nil) // Acquire attempts to write or renew one postgres lease row for the requested // outpoint. func (o *pgLeaseOutputOps) Acquire(ctx context.Context, - params LeaseOutputParams, nowUTC time.Time, + params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return time.Time{}, fmt.Errorf("convert output index: %w", err) } @@ -73,18 +74,18 @@ func (o *pgLeaseOutputOps) Acquire(ctx context.Context, } if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, ErrLeaseOutputNoRow + return time.Time{}, db.ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) } // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, - params LeaseOutputParams) (bool, error) { + params db.LeaseOutputParams) (bool, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return false, fmt.Errorf("convert output index: %w", err) } diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go similarity index 74% rename from wallet/internal/db/postgres_utxostore_listleasedoutputs.go rename to wallet/internal/db/pg/utxostore_listleasedoutputs.go index 79f8d522f2..846b17b9ad 100644 --- a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -10,7 +11,7 @@ import ( // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, - walletID uint32) ([]LeasedOutput, error) { + walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() @@ -24,14 +25,14 @@ func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, return nil, fmt.Errorf("list active utxo leases: %w", err) } - leases := make([]LeasedOutput, len(rows)) + leases := make([]db.LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := Int64ToUint32(int64(row.OutputIndex)) + outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := BuildLeasedOutput( + lease, err := db.BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go similarity index 69% rename from wallet/internal/db/postgres_utxostore_listutxos.go rename to wallet/internal/db/pg/utxostore_listutxos.go index fbbb76666b..ccf2376e5a 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -12,14 +13,14 @@ import ( // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. func (s *PostgresStore) ListUTXOs(ctx context.Context, - query ListUtxosQuery) ([]UtxoInfo, error) { + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } - utxos := make([]UtxoInfo, len(rows)) + utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { utxo, err := utxoInfoFromPgRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, @@ -37,11 +38,11 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, // buildListUtxosParamsPg prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { +func buildListUtxosParamsPg(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: NullableUint32ToSQLInt64(query.Account), - MinConfirms: NullableInt32ToSQLInt32(query.MinConfs), - MaxConfirms: NullableInt32ToSQLInt32(query.MaxConfs), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(query.MaxConfs), } } diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go similarity index 80% rename from wallet/internal/db/postgres_utxostore_releaseoutput.go rename to wallet/internal/db/pg/utxostore_releaseoutput.go index 5557e03a5b..ff6b04fc8e 100644 --- a/wallet/internal/db/postgres_utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,10 +17,10 @@ import ( // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. func (s *PostgresStore) ReleaseOutput(ctx context.Context, - params ReleaseOutputParams) error { + params db.ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return ReleaseOutputWithOps( + return db.ReleaseOutputWithOps( ctx, params, &pgReleaseOutputOps{qtx: qtx}, ) }) @@ -31,14 +32,14 @@ type pgReleaseOutputOps struct { qtx *sqlcpg.Queries } -var _ ReleaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*pgReleaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, - params ReleaseOutputParams) (int64, error) { + params db.ReleaseOutputParams) (int64, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return 0, err } @@ -52,7 +53,7 @@ func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, ErrReleaseOutputUtxoNotFound + return 0, db.ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -74,7 +75,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("Release lease row: %w", err) + return 0, fmt.Errorf("release lease row: %w", err) } return rows, nil @@ -85,7 +86,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -94,11 +95,11 @@ func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrReleaseOutputNoActiveLease + return nil, db.ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return ActiveLockID, nil + return activeLockID, nil } diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/pg/wallet.go similarity index 89% rename from wallet/internal/db/wallet_pg.go rename to wallet/internal/db/pg/wallet.go index 8d3f212c77..f9a8242ddf 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/pg/wallet.go @@ -1,4 +1,4 @@ -package db +package pg import ( "context" @@ -7,20 +7,21 @@ import ( "fmt" "iter" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // Ensure PostgresStore satisfies the WalletStore interface. -var _ WalletStore = (*PostgresStore)(nil) +var _ db.WalletStore = (*PostgresStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. func (s *PostgresStore) CreateWallet(ctx context.Context, - params CreateWalletParams) (*WalletInfo, error) { + params db.CreateWalletParams) (*db.WalletInfo, error) { - var info *WalletInfo + var info *db.WalletInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ @@ -114,13 +115,13 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. func (s *PostgresStore) GetWallet(ctx context.Context, - name string) (*WalletInfo, error) { + name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, - ErrWalletNotFound) + db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet: %w", err) @@ -144,19 +145,19 @@ func (s *PostgresStore) GetWallet(ctx context.Context, // ListWallets returns a page of wallets matching the given query. func (s *PostgresStore) ListWallets(ctx context.Context, - query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { + query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) if err != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) } - items := make([]WalletInfo, len(rows)) + items := make([]db.WalletInfo, len(rows)) for i, row := range rows { item, errMap := pgListWalletRowToInfo(row) if errMap != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) } @@ -165,7 +166,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, result := page.BuildResult( query.Page, items, - func(item WalletInfo) uint32 { + func(item db.WalletInfo) uint32 { return item.ID }, ) @@ -175,10 +176,10 @@ func (s *PostgresStore) ListWallets(ctx context.Context, // IterWallets returns an iterator over paginated wallet results. func (s *PostgresStore) IterWallets(ctx context.Context, - query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, NextListWalletsQuery, + ctx, query, s.ListWallets, db.NextListWalletsQuery, ) } @@ -187,7 +188,7 @@ func (s *PostgresStore) IterWallets(ctx context.Context, // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. func (s *PostgresStore) UpdateWallet(ctx context.Context, - params UpdateWalletParams) error { + params db.UpdateWalletParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. @@ -221,7 +222,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet sync state for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -239,7 +240,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, ErrWalletNotFound) + walletID, db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet secrets: %w", err) @@ -248,7 +249,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, if len(secrets.EncryptedMasterHdPrivKey) == 0 { return nil, fmt.Errorf( "encrypted master privkey for wallet %d: %w", walletID, - ErrSecretNotFound) + db.ErrSecretNotFound) } return secrets.EncryptedMasterHdPrivKey, nil @@ -256,7 +257,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, // UpdateWalletSecrets updates the secrets for the wallet. func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, - params UpdateWalletSecretsParams) error { + params db.UpdateWalletSecretsParams) error { secretsParams := sqlcpg.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, @@ -273,7 +274,7 @@ func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -298,7 +299,7 @@ type pgWalletRowParams struct { // pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*WalletInfo, error) { +func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { return buildPgWalletInfo(pgWalletRowParams{ id: row.ID, name: row.WalletName, @@ -336,13 +337,13 @@ func pgListWalletsParams( // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. -func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { - walletID, err := Int64ToUint32(row.id) +func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { + walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err } - info := &WalletInfo{ + info := &db.WalletInfo{ ID: walletID, Name: row.name, IsImported: row.isImported, @@ -385,7 +386,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { // buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsPg(params UpdateWalletParams) ( +func buildUpdateSyncParamsPg(params db.UpdateWalletParams) ( sqlcpg.UpdateWalletSyncStateParams, error) { syncParams := sqlcpg.UpdateWalletSyncStateParams{ @@ -393,7 +394,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.SyncedTo != nil { - syncedHeight, err := Uint32ToNullInt32(params.SyncedTo.Height) + syncedHeight, err := db.Uint32ToNullInt32(params.SyncedTo.Height) if err != nil { return syncParams, err } @@ -409,7 +410,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.BirthdayBlock != nil { - birthdayHeight, err := Uint32ToNullInt32( + birthdayHeight, err := db.Uint32ToNullInt32( params.BirthdayBlock.Height, ) if err != nil { From 69e459bc66c5daf00f19eedcf500a694a65d27e1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:28:51 +0800 Subject: [PATCH 224/695] sql/sqlite: move sql assets Move the SQLite migrations, handwritten queries, and generated sqlc output into wallet/internal/sql/sqlite. The SQLite store and sqlite itest helpers now import the moved sqlc package, and migration loading comes from the new sqlite SQL asset package. This keeps the asset move backend-scoped so the later postgres SQL asset split can follow the same path independently. --- sqlc.yaml | 6 +-- .../internal/db/itest/fixtures_sqlite_test.go | 2 +- wallet/internal/db/itest/sqlite_test.go | 2 +- wallet/internal/db/migrations.go | 15 ------ wallet/internal/db/pg/backend_rows_test.go | 1 + wallet/internal/db/sqlite/accounts.go | 2 +- wallet/internal/db/sqlite/address_types.go | 2 +- wallet/internal/db/sqlite/addresses.go | 2 +- .../internal/db/sqlite/backend_error_test.go | 2 +- .../internal/db/sqlite/backend_rows_test.go | 2 +- wallet/internal/db/sqlite/block.go | 2 +- wallet/internal/db/sqlite/itest.go | 2 +- wallet/internal/db/sqlite/store.go | 6 +-- wallet/internal/db/sqlite/txstore_createtx.go | 2 +- wallet/internal/db/sqlite/txstore_deletetx.go | 2 +- wallet/internal/db/sqlite/txstore_gettx.go | 2 +- .../db/sqlite/txstore_invalidateunmined.go | 2 +- wallet/internal/db/sqlite/txstore_listtxns.go | 2 +- wallet/internal/db/sqlite/txstore_rollback.go | 2 +- wallet/internal/db/sqlite/txstore_updatetx.go | 2 +- .../internal/db/sqlite/utxostore_balance.go | 2 +- .../internal/db/sqlite/utxostore_getutxo.go | 2 +- .../db/sqlite/utxostore_leaseoutput.go | 2 +- .../db/sqlite/utxostore_listleasedoutputs.go | 2 +- .../internal/db/sqlite/utxostore_listutxos.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 2 +- wallet/internal/db/sqlite/wallet.go | 2 +- wallet/internal/sql/sqlite/doc.go | 2 + wallet/internal/sql/sqlite/migrations.go | 50 +++++++++++++++++++ .../sqlite/migrations}/000001_blocks.down.sql | 0 .../sqlite/migrations}/000001_blocks.up.sql | 0 .../migrations}/000002_wallets.down.sql | 0 .../sqlite/migrations}/000002_wallets.up.sql | 0 .../migrations}/000003_address_types.down.sql | 0 .../migrations}/000003_address_types.up.sql | 0 .../migrations}/000004_key_scopes.down.sql | 0 .../migrations}/000004_key_scopes.up.sql | 0 .../migrations}/000005_accounts.down.sql | 0 .../sqlite/migrations}/000005_accounts.up.sql | 0 .../migrations}/000006_addresses.down.sql | 0 .../migrations}/000006_addresses.up.sql | 0 .../migrations}/000007_transactions.down.sql | 0 .../migrations}/000007_transactions.up.sql | 0 .../sqlite/migrations}/000008_utxos.down.sql | 0 .../sqlite/migrations}/000008_utxos.up.sql | 0 .../000009_tx_replacements.down.sql | 0 .../migrations}/000009_tx_replacements.up.sql | 0 .../migrations}/000010_utxo_leases.down.sql | 0 .../migrations}/000010_utxo_leases.up.sql | 0 .../sqlite/queries}/accounts.sql | 0 .../sqlite/queries}/address_types.sql | 0 .../sqlite/queries}/addresses.sql | 0 .../sqlite => sql/sqlite/queries}/blocks.sql | 0 .../sqlite/queries}/key_scopes.sql | 0 .../sqlite/queries}/transactions.sql | 0 .../sqlite/queries}/tx_replacements.sql | 0 .../sqlite/queries}/utxo_leases.sql | 0 .../sqlite => sql/sqlite/queries}/utxos.sql | 0 .../sqlite => sql/sqlite/queries}/wallets.sql | 0 .../sqlite/sqlc}/accounts.sql.go | 0 .../sqlite/sqlc}/address_types.sql.go | 0 .../sqlite/sqlc}/addresses.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/blocks.sql.go | 0 .../{db/sqlc/sqlite => sql/sqlite/sqlc}/db.go | 0 .../sqlite/sqlc}/key_scopes.sql.go | 0 .../sqlc/sqlite => sql/sqlite/sqlc}/models.go | 0 .../sqlite => sql/sqlite/sqlc}/querier.go | 0 .../sqlite/sqlc}/transactions.sql.go | 0 .../sqlite/sqlc}/tx_replacements.sql.go | 0 .../sqlite/sqlc}/utxo_leases.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/utxos.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/wallets.sql.go | 0 72 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 wallet/internal/sql/sqlite/doc.go create mode 100644 wallet/internal/sql/sqlite/migrations.go rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000001_blocks.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000001_blocks.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000002_wallets.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000002_wallets.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000003_address_types.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000003_address_types.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000004_key_scopes.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000004_key_scopes.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000005_accounts.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000005_accounts.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000006_addresses.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000006_addresses.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000007_transactions.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000007_transactions.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000008_utxos.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000008_utxos.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000009_tx_replacements.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000009_tx_replacements.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000010_utxo_leases.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000010_utxo_leases.up.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/accounts.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/address_types.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/addresses.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/blocks.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/key_scopes.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/transactions.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/tx_replacements.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/utxo_leases.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/utxos.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/wallets.sql (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/accounts.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/address_types.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/addresses.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/blocks.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/db.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/key_scopes.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/models.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/querier.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/transactions.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/tx_replacements.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/utxo_leases.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/utxos.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/wallets.sql.go (100%) diff --git a/sqlc.yaml b/sqlc.yaml index 3bed14788a..9b322becd0 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "wallet/internal/db/migrations/sqlite" - queries: "wallet/internal/db/queries/sqlite" + schema: "wallet/internal/sql/sqlite/migrations" + queries: "wallet/internal/sql/sqlite/queries" gen: go: - out: "wallet/internal/db/sqlc/sqlite" + out: "wallet/internal/sql/sqlite/sqlc" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index a3a4cae69c..cb0a8d5d6c 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index dc4c8dce27..fad55c422b 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -11,8 +11,8 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go index cc929d1f35..f6a419d7ea 100644 --- a/wallet/internal/db/migrations.go +++ b/wallet/internal/db/migrations.go @@ -10,13 +10,9 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database/postgres" - "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" ) -//go:embed migrations/sqlite/*.sql -var sqliteFS embed.FS - //go:embed migrations/postgres/*.sql var postgresFS embed.FS @@ -61,17 +57,6 @@ func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, return nil } -// ApplySQLiteMigrations applies all SQLite migrations to the database. -// -// NOTE: not ready for production use. -func ApplySQLiteMigrations(db *sql.DB) error { - return applyMigrations(db, sqliteFS, "migrations/sqlite", "sqlite", - func(db *sql.DB) (database.Driver, error) { - return sqlite.WithInstance(db, &sqlite.Config{}) - }, - ) -} - // ApplyPostgresMigrations applies all PostgreSQL migrations to the database. // // NOTE: not ready for production use. diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 4d04b784b8..fe9bd6fbfa 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" ) // staticResult is a minimal sql.Result stub with a caller-controlled row count. diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 50a656dad8..66c96b48b5 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure SqliteStore satisfies the AccountStore interface. diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index d95b24593b..bdea9bb339 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -4,7 +4,7 @@ import ( "context" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // sqliteAddressTypeRowToInfo converts a SQLite address type row to an diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index a99357e37d..c98845a245 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) var _ db.AddressStore = (*SqliteStore)(nil) diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index 7ff820bb72..872411ea98 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index 8714e533ae..e727492616 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index abf6c938b3..5306caeeca 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -8,7 +8,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildSqliteBlock constructs a Block from the given SQLite block diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index f5204d8dc4..16144c37a7 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,7 +5,7 @@ package sqlite import ( "database/sql" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index a06ab5b188..5fce98f1e7 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -6,8 +6,8 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) @@ -61,7 +61,7 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, queries := sqlcsqlite.New(dbConn) - err = db.ApplySQLiteMigrations(dbConn) + err = sqlassetsqlite.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index a7ea3ab8b3..26c94f5235 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 8b90c3e080..06032ce826 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index b90fde980a..fc8048fa28 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 659bc73951..5a0c0bea12 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index c332406f39..673f953171 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index d68f224585..a170faf82a 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -7,7 +7,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index dd8a13f183..af5c597a4a 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index d384f5b22a..1ec9cc852b 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index 30a3bf3adb..a63615b790 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 2874ca98f7..177644f847 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 52d6a03f93..b4f1b70d68 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -6,7 +6,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 1efb716c5b..dd47d02cfb 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -5,7 +5,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index 461fe4e1cb..dd716d9228 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index f58cf5a500..8e687e253b 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure SqliteStore satisfies the WalletStore interface. diff --git a/wallet/internal/sql/sqlite/doc.go b/wallet/internal/sql/sqlite/doc.go new file mode 100644 index 0000000000..8293bddf01 --- /dev/null +++ b/wallet/internal/sql/sqlite/doc.go @@ -0,0 +1,2 @@ +// Package sqlite contains SQLite SQL assets and migration helpers. +package sqlite diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go new file mode 100644 index 0000000000..9bea555e9c --- /dev/null +++ b/wallet/internal/sql/sqlite/migrations.go @@ -0,0 +1,50 @@ +package sqlite + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + gomigrate "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + migrate "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations applies all embedded sqlite migrations to one database. +func applyMigrations(db *sql.DB, newDriver driverFactory) error { + sourceDriver, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create sqlite driver: %w", err) + } + + m, err := gomigrate.NewWithInstance("iofs", sourceDriver, "sqlite", driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplyMigrations applies all SQLite migrations to the database. +func ApplyMigrations(db *sql.DB) error { + return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { + return migrate.WithInstance(db, &migrate.Config{}) + }) +} diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.down.sql b/wallet/internal/sql/sqlite/migrations/000001_blocks.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000001_blocks.down.sql rename to wallet/internal/sql/sqlite/migrations/000001_blocks.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/sql/sqlite/migrations/000001_blocks.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000001_blocks.up.sql rename to wallet/internal/sql/sqlite/migrations/000001_blocks.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql b/wallet/internal/sql/sqlite/migrations/000002_wallets.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000002_wallets.down.sql rename to wallet/internal/sql/sqlite/migrations/000002_wallets.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/sql/sqlite/migrations/000002_wallets.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000002_wallets.up.sql rename to wallet/internal/sql/sqlite/migrations/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql b/wallet/internal/sql/sqlite/migrations/000003_address_types.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000003_address_types.down.sql rename to wallet/internal/sql/sqlite/migrations/000003_address_types.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/sql/sqlite/migrations/000003_address_types.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000003_address_types.up.sql rename to wallet/internal/sql/sqlite/migrations/000003_address_types.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql rename to wallet/internal/sql/sqlite/migrations/000004_key_scopes.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql rename to wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000005_accounts.down.sql rename to wallet/internal/sql/sqlite/migrations/000005_accounts.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000005_accounts.up.sql rename to wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000006_addresses.down.sql rename to wallet/internal/sql/sqlite/migrations/000006_addresses.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000006_addresses.up.sql rename to wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql b/wallet/internal/sql/sqlite/migrations/000007_transactions.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000007_transactions.down.sql rename to wallet/internal/sql/sqlite/migrations/000007_transactions.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/sql/sqlite/migrations/000007_transactions.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000007_transactions.up.sql rename to wallet/internal/sql/sqlite/migrations/000007_transactions.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql b/wallet/internal/sql/sqlite/migrations/000008_utxos.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000008_utxos.down.sql rename to wallet/internal/sql/sqlite/migrations/000008_utxos.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql b/wallet/internal/sql/sqlite/migrations/000008_utxos.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000008_utxos.up.sql rename to wallet/internal/sql/sqlite/migrations/000008_utxos.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql b/wallet/internal/sql/sqlite/migrations/000009_tx_replacements.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql rename to wallet/internal/sql/sqlite/migrations/000009_tx_replacements.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql b/wallet/internal/sql/sqlite/migrations/000009_tx_replacements.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql rename to wallet/internal/sql/sqlite/migrations/000009_tx_replacements.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql b/wallet/internal/sql/sqlite/migrations/000010_utxo_leases.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql rename to wallet/internal/sql/sqlite/migrations/000010_utxo_leases.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql b/wallet/internal/sql/sqlite/migrations/000010_utxo_leases.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql rename to wallet/internal/sql/sqlite/migrations/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/accounts.sql rename to wallet/internal/sql/sqlite/queries/accounts.sql diff --git a/wallet/internal/db/queries/sqlite/address_types.sql b/wallet/internal/sql/sqlite/queries/address_types.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/address_types.sql rename to wallet/internal/sql/sqlite/queries/address_types.sql diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/addresses.sql rename to wallet/internal/sql/sqlite/queries/addresses.sql diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/sql/sqlite/queries/blocks.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/blocks.sql rename to wallet/internal/sql/sqlite/queries/blocks.sql diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/sql/sqlite/queries/key_scopes.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/key_scopes.sql rename to wallet/internal/sql/sqlite/queries/key_scopes.sql diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/sql/sqlite/queries/transactions.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/transactions.sql rename to wallet/internal/sql/sqlite/queries/transactions.sql diff --git a/wallet/internal/db/queries/sqlite/tx_replacements.sql b/wallet/internal/sql/sqlite/queries/tx_replacements.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/tx_replacements.sql rename to wallet/internal/sql/sqlite/queries/tx_replacements.sql diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/sql/sqlite/queries/utxo_leases.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/utxo_leases.sql rename to wallet/internal/sql/sqlite/queries/utxo_leases.sql diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/sql/sqlite/queries/utxos.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/utxos.sql rename to wallet/internal/sql/sqlite/queries/utxos.sql diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/sql/sqlite/queries/wallets.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/wallets.sql rename to wallet/internal/sql/sqlite/queries/wallets.sql diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/accounts.sql.go rename to wallet/internal/sql/sqlite/sqlc/accounts.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/address_types.sql.go b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/address_types.sql.go rename to wallet/internal/sql/sqlite/sqlc/address_types.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/addresses.sql.go rename to wallet/internal/sql/sqlite/sqlc/addresses.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/blocks.sql.go rename to wallet/internal/sql/sqlite/sqlc/blocks.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/sql/sqlite/sqlc/db.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/db.go rename to wallet/internal/sql/sqlite/sqlc/db.go diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/key_scopes.sql.go rename to wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/sql/sqlite/sqlc/models.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/models.go rename to wallet/internal/sql/sqlite/sqlc/models.go diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/querier.go rename to wallet/internal/sql/sqlite/sqlc/querier.go diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/transactions.sql.go rename to wallet/internal/sql/sqlite/sqlc/transactions.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go rename to wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go rename to wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/utxos.sql.go rename to wallet/internal/sql/sqlite/sqlc/utxos.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/wallets.sql.go rename to wallet/internal/sql/sqlite/sqlc/wallets.sql.go From 594bb0d4ac0518b0d02be2899e87ae4bf89a1b34 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:31:06 +0800 Subject: [PATCH 225/695] sql/pg: move sql assets Move the PostgreSQL migrations, handwritten queries, and generated sqlc output into wallet/internal/sql/pg. The PostgreSQL store and pg itest helpers now import the moved sqlc package, and migration loading comes from the new pg SQL asset package. This completes the backend-scoped SQL asset split so both backends now follow the same internal/sql layout. --- Makefile | 15 ++-- sqlc.yaml | 6 +- wallet/internal/db/itest/fixtures_pg_test.go | 2 +- wallet/internal/db/itest/pg_test.go | 2 +- wallet/internal/db/migrations.go | 69 ------------------- wallet/internal/db/pg/accounts.go | 2 +- wallet/internal/db/pg/address_types.go | 2 +- wallet/internal/db/pg/addresses.go | 2 +- wallet/internal/db/pg/backend_error_test.go | 2 +- wallet/internal/db/pg/backend_rows_test.go | 2 +- wallet/internal/db/pg/block.go | 2 +- wallet/internal/db/pg/itest.go | 2 +- wallet/internal/db/pg/store.go | 5 +- wallet/internal/db/pg/txstore_createtx.go | 2 +- wallet/internal/db/pg/txstore_deletetx.go | 2 +- wallet/internal/db/pg/txstore_gettx.go | 2 +- .../db/pg/txstore_invalidateunmined.go | 2 +- wallet/internal/db/pg/txstore_listtxns.go | 2 +- wallet/internal/db/pg/txstore_rollback.go | 2 +- wallet/internal/db/pg/txstore_updatetx.go | 2 +- wallet/internal/db/pg/utxostore_balance.go | 2 +- wallet/internal/db/pg/utxostore_getutxo.go | 2 +- .../internal/db/pg/utxostore_leaseoutput.go | 2 +- .../db/pg/utxostore_listleasedoutputs.go | 2 +- wallet/internal/db/pg/utxostore_listutxos.go | 2 +- .../internal/db/pg/utxostore_releaseoutput.go | 2 +- wallet/internal/db/pg/wallet.go | 2 +- wallet/internal/sql/pg/doc.go | 2 + wallet/internal/sql/pg/migrations.go | 52 ++++++++++++++ .../pg/migrations}/000001_blocks.down.sql | 0 .../pg/migrations}/000001_blocks.up.sql | 0 .../pg/migrations}/000002_wallets.down.sql | 0 .../pg/migrations}/000002_wallets.up.sql | 0 .../migrations}/000003_address_types.down.sql | 0 .../migrations}/000003_address_types.up.sql | 0 .../pg/migrations}/000004_key_scopes.down.sql | 0 .../pg/migrations}/000004_key_scopes.up.sql | 0 .../pg/migrations}/000005_accounts.down.sql | 0 .../pg/migrations}/000005_accounts.up.sql | 0 .../pg/migrations}/000006_addresses.down.sql | 0 .../pg/migrations}/000006_addresses.up.sql | 0 .../migrations}/000007_transactions.down.sql | 0 .../pg/migrations}/000007_transactions.up.sql | 0 .../pg/migrations}/000008_utxos.down.sql | 0 .../pg/migrations}/000008_utxos.up.sql | 0 .../000009_tx_replacements.down.sql | 0 .../migrations}/000009_tx_replacements.up.sql | 0 .../migrations}/000010_utxo_leases.down.sql | 0 .../pg/migrations}/000010_utxo_leases.up.sql | 0 .../postgres => sql/pg/queries}/accounts.sql | 0 .../pg/queries}/address_types.sql | 0 .../postgres => sql/pg/queries}/addresses.sql | 0 .../postgres => sql/pg/queries}/blocks.sql | 0 .../pg/queries}/key_scopes.sql | 0 .../pg/queries}/transactions.sql | 0 .../pg/queries}/tx_replacements.sql | 0 .../pg/queries}/utxo_leases.sql | 0 .../postgres => sql/pg/queries}/utxos.sql | 0 .../postgres => sql/pg/queries}/wallets.sql | 0 .../postgres => sql/pg/sqlc}/accounts.sql.go | 0 .../pg/sqlc}/address_types.sql.go | 0 .../postgres => sql/pg/sqlc}/addresses.sql.go | 0 .../postgres => sql/pg/sqlc}/blocks.sql.go | 0 .../{db/sqlc/postgres => sql/pg/sqlc}/db.go | 0 .../pg/sqlc}/key_scopes.sql.go | 0 .../sqlc/postgres => sql/pg/sqlc}/models.go | 0 .../sqlc/postgres => sql/pg/sqlc}/querier.go | 0 .../pg/sqlc}/transactions.sql.go | 0 .../pg/sqlc}/tx_replacements.sql.go | 0 .../pg/sqlc}/utxo_leases.sql.go | 0 .../postgres => sql/pg/sqlc}/utxos.sql.go | 0 .../postgres => sql/pg/sqlc}/wallets.sql.go | 0 72 files changed, 91 insertions(+), 104 deletions(-) delete mode 100644 wallet/internal/db/migrations.go create mode 100644 wallet/internal/sql/pg/doc.go create mode 100644 wallet/internal/sql/pg/migrations.go rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000001_blocks.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000001_blocks.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000002_wallets.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000002_wallets.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000003_address_types.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000003_address_types.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000004_key_scopes.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000004_key_scopes.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000005_accounts.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000005_accounts.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000006_addresses.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000006_addresses.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000007_transactions.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000007_transactions.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000008_utxos.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000008_utxos.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000009_tx_replacements.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000009_tx_replacements.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000010_utxo_leases.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000010_utxo_leases.up.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/accounts.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/address_types.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/addresses.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/blocks.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/key_scopes.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/transactions.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/tx_replacements.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/utxo_leases.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/utxos.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/wallets.sql (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/accounts.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/address_types.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/addresses.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/blocks.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/db.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/key_scopes.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/models.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/querier.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/transactions.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/tx_replacements.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/utxo_leases.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/utxos.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/wallets.sql.go (100%) diff --git a/Makefile b/Makefile index f8ebe01f54..e4e70258dd 100644 --- a/Makefile +++ b/Makefile @@ -8,14 +8,15 @@ GOFILES = $(shell find . -type f -name '*.go' -not -name "*.pb.go") # SQL directories. -SQL_MIGRATIONS_DIR := wallet/internal/db/migrations -SQL_QUERIES_DIR := wallet/internal/db/queries +SQL_DIR := wallet/internal/sql # SQL file paths. -SQL_POSTGRES_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/postgres -SQL_POSTGRES_QUERIES := $(SQL_QUERIES_DIR)/postgres -SQL_SQLITE_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/sqlite -SQL_SQLITE_QUERIES := $(SQL_QUERIES_DIR)/sqlite +SQL_POSTGRES_DIR := $(SQL_DIR)/pg +SQL_POSTGRES_MIGRATIONS := $(SQL_POSTGRES_DIR)/migrations +SQL_POSTGRES_QUERIES := $(SQL_POSTGRES_DIR)/queries +SQL_SQLITE_DIR := $(SQL_DIR)/sqlite +SQL_SQLITE_MIGRATIONS := $(SQL_SQLITE_DIR)/migrations +SQL_SQLITE_QUERIES := $(SQL_SQLITE_DIR)/queries RM := rm -f CP := cp @@ -262,7 +263,7 @@ sql-format: #? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') sql-format-check: sql-format @$(call print, "Checking SQL formatting.") - if test -n "$$(git status --porcelain '$(SQL_MIGRATIONS_DIR)/**/*.sql' '$(SQL_QUERIES_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi + if test -n "$$(git status --porcelain '$(SQL_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi #? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') sql-lint: diff --git a/sqlc.yaml b/sqlc.yaml index 9b322becd0..1ee7f931f1 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "wallet/internal/db/migrations/postgres" - queries: "wallet/internal/db/queries/postgres" + schema: "wallet/internal/sql/pg/migrations" + queries: "wallet/internal/sql/pg/queries" gen: go: - out: "wallet/internal/db/sqlc/postgres" + out: "wallet/internal/sql/pg/sqlc" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 4b33892cee..540d1443ac 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index dc013fb096..9cbcce1800 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -21,7 +21,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go deleted file mode 100644 index f6a419d7ea..0000000000 --- a/wallet/internal/db/migrations.go +++ /dev/null @@ -1,69 +0,0 @@ -package db - -import ( - "database/sql" - "embed" - "errors" - "fmt" - "io/fs" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" - "github.com/golang-migrate/migrate/v4/database/postgres" - "github.com/golang-migrate/migrate/v4/source/iofs" -) - -//go:embed migrations/postgres/*.sql -var postgresFS embed.FS - -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations is a simple function that applies all migrations found in the -// given migrationFS at the given path to the provided database using the given -// driver factory. -// -// TODO(gustavostingelin): enhance migrations to be like sqldb/v2 before -// production use. This is a simplified migration system suitable for -// integration tests but lacks features required for production: -// - No migration version tracking or status checks -// - No migration history table or audit trail -// - No protection against concurrent migrations -// -// For production use, this should be enhanced to match the patterns in -// lnd/sqldb/v2, which provides a more robust migration framework. -func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, - newDriver driverFactory) error { - - sourceDriver, err := iofs.New(migrationFS, path) - if err != nil { - return fmt.Errorf("create source driver: %w", err) - } - - driver, err := newDriver(db) - if err != nil { - return fmt.Errorf("create %s driver: %w", dbName, err) - } - - m, err := migrate.NewWithInstance("iofs", sourceDriver, dbName, driver) - if err != nil { - return fmt.Errorf("create migrate instance: %w", err) - } - - err = m.Up() - if err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("run migrations: %w", err) - } - - return nil -} - -// ApplyPostgresMigrations applies all PostgreSQL migrations to the database. -// -// NOTE: not ready for production use. -func ApplyPostgresMigrations(db *sql.DB) error { - return applyMigrations(db, postgresFS, "migrations/postgres", - "postgres", func(db *sql.DB) (database.Driver, error) { - return postgres.WithInstance(db, &postgres.Config{}) - }, - ) -} diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index fb9bdbafb7..2ee775c5c1 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure PostgresStore satisfies the AccountStore interface. diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 7aa90025f4..46388b9d09 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -4,7 +4,7 @@ import ( "context" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 41f56871d2..3a35ad3c88 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) var _ db.AddressStore = (*PostgresStore)(nil) diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index 63dfc0860c..fbb70e1fcc 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -10,7 +10,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index fe9bd6fbfa..8da714683c 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index fc8559d2f1..4cd4881d40 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -8,7 +8,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildPgBlock constructs a Block from the given PostgreSQL block diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 89dc9d4d87..f9f9b1a441 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -6,7 +6,7 @@ import ( "database/sql" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index c351bd45f8..7f0e131d73 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -6,8 +6,9 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -54,7 +55,7 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor queries := sqlcpg.New(dbConn) - err = db.ApplyPostgresMigrations(dbConn) + err = sqlassetpg.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index ebd624d3b9..8e40a65766 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 97bd27c969..578e560a3a 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index 8e1bbac1bf..abbebf3f22 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index 585ad519f5..1a0d349dfe 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 8438214d0e..574f27a485 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index fcd5dfb798..836ce9a564 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -7,7 +7,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 7103ee27a2..f584b44cb0 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index a5cf022c2b..6f2ecd6aac 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 9e58067f85..3f07f0bea3 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 9f17905edf..82c57727d4 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 846b17b9ad..926175b55f 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -6,7 +6,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index ccf2376e5a..888a9186c9 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -5,7 +5,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index ff6b04fc8e..694fd4668c 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index f9a8242ddf..a83070270d 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure PostgresStore satisfies the WalletStore interface. diff --git a/wallet/internal/sql/pg/doc.go b/wallet/internal/sql/pg/doc.go new file mode 100644 index 0000000000..090cde30ab --- /dev/null +++ b/wallet/internal/sql/pg/doc.go @@ -0,0 +1,2 @@ +// Package pg contains PostgreSQL SQL assets and migration helpers. +package pg diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go new file mode 100644 index 0000000000..a59810dcab --- /dev/null +++ b/wallet/internal/sql/pg/migrations.go @@ -0,0 +1,52 @@ +package pg + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + gomigrate "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + migrate "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations applies all embedded postgres migrations to one database. +func applyMigrations(db *sql.DB, newDriver driverFactory) error { + sourceDriver, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create postgres driver: %w", err) + } + + m, err := gomigrate.NewWithInstance( + "iofs", sourceDriver, "postgres", driver, + ) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplyMigrations applies all PostgreSQL migrations to the database. +func ApplyMigrations(db *sql.DB) error { + return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { + return migrate.WithInstance(db, &migrate.Config{}) + }) +} diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.down.sql b/wallet/internal/sql/pg/migrations/000001_blocks.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000001_blocks.down.sql rename to wallet/internal/sql/pg/migrations/000001_blocks.down.sql diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/sql/pg/migrations/000001_blocks.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000001_blocks.up.sql rename to wallet/internal/sql/pg/migrations/000001_blocks.up.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.down.sql b/wallet/internal/sql/pg/migrations/000002_wallets.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000002_wallets.down.sql rename to wallet/internal/sql/pg/migrations/000002_wallets.down.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/sql/pg/migrations/000002_wallets.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000002_wallets.up.sql rename to wallet/internal/sql/pg/migrations/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.down.sql b/wallet/internal/sql/pg/migrations/000003_address_types.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000003_address_types.down.sql rename to wallet/internal/sql/pg/migrations/000003_address_types.down.sql diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/sql/pg/migrations/000003_address_types.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000003_address_types.up.sql rename to wallet/internal/sql/pg/migrations/000003_address_types.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql rename to wallet/internal/sql/pg/migrations/000004_key_scopes.down.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql rename to wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.down.sql b/wallet/internal/sql/pg/migrations/000005_accounts.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000005_accounts.down.sql rename to wallet/internal/sql/pg/migrations/000005_accounts.down.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000005_accounts.up.sql rename to wallet/internal/sql/pg/migrations/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000006_addresses.down.sql rename to wallet/internal/sql/pg/migrations/000006_addresses.down.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000006_addresses.up.sql rename to wallet/internal/sql/pg/migrations/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.down.sql b/wallet/internal/sql/pg/migrations/000007_transactions.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000007_transactions.down.sql rename to wallet/internal/sql/pg/migrations/000007_transactions.down.sql diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/sql/pg/migrations/000007_transactions.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000007_transactions.up.sql rename to wallet/internal/sql/pg/migrations/000007_transactions.up.sql diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.down.sql b/wallet/internal/sql/pg/migrations/000008_utxos.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000008_utxos.down.sql rename to wallet/internal/sql/pg/migrations/000008_utxos.down.sql diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.up.sql b/wallet/internal/sql/pg/migrations/000008_utxos.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000008_utxos.up.sql rename to wallet/internal/sql/pg/migrations/000008_utxos.up.sql diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql b/wallet/internal/sql/pg/migrations/000009_tx_replacements.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql rename to wallet/internal/sql/pg/migrations/000009_tx_replacements.down.sql diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql b/wallet/internal/sql/pg/migrations/000009_tx_replacements.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql rename to wallet/internal/sql/pg/migrations/000009_tx_replacements.up.sql diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql b/wallet/internal/sql/pg/migrations/000010_utxo_leases.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql rename to wallet/internal/sql/pg/migrations/000010_utxo_leases.down.sql diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql b/wallet/internal/sql/pg/migrations/000010_utxo_leases.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql rename to wallet/internal/sql/pg/migrations/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql similarity index 100% rename from wallet/internal/db/queries/postgres/accounts.sql rename to wallet/internal/sql/pg/queries/accounts.sql diff --git a/wallet/internal/db/queries/postgres/address_types.sql b/wallet/internal/sql/pg/queries/address_types.sql similarity index 100% rename from wallet/internal/db/queries/postgres/address_types.sql rename to wallet/internal/sql/pg/queries/address_types.sql diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql similarity index 100% rename from wallet/internal/db/queries/postgres/addresses.sql rename to wallet/internal/sql/pg/queries/addresses.sql diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/sql/pg/queries/blocks.sql similarity index 100% rename from wallet/internal/db/queries/postgres/blocks.sql rename to wallet/internal/sql/pg/queries/blocks.sql diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/sql/pg/queries/key_scopes.sql similarity index 100% rename from wallet/internal/db/queries/postgres/key_scopes.sql rename to wallet/internal/sql/pg/queries/key_scopes.sql diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/sql/pg/queries/transactions.sql similarity index 100% rename from wallet/internal/db/queries/postgres/transactions.sql rename to wallet/internal/sql/pg/queries/transactions.sql diff --git a/wallet/internal/db/queries/postgres/tx_replacements.sql b/wallet/internal/sql/pg/queries/tx_replacements.sql similarity index 100% rename from wallet/internal/db/queries/postgres/tx_replacements.sql rename to wallet/internal/sql/pg/queries/tx_replacements.sql diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/sql/pg/queries/utxo_leases.sql similarity index 100% rename from wallet/internal/db/queries/postgres/utxo_leases.sql rename to wallet/internal/sql/pg/queries/utxo_leases.sql diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/sql/pg/queries/utxos.sql similarity index 100% rename from wallet/internal/db/queries/postgres/utxos.sql rename to wallet/internal/sql/pg/queries/utxos.sql diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/sql/pg/queries/wallets.sql similarity index 100% rename from wallet/internal/db/queries/postgres/wallets.sql rename to wallet/internal/sql/pg/queries/wallets.sql diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/accounts.sql.go rename to wallet/internal/sql/pg/sqlc/accounts.sql.go diff --git a/wallet/internal/db/sqlc/postgres/address_types.sql.go b/wallet/internal/sql/pg/sqlc/address_types.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/address_types.sql.go rename to wallet/internal/sql/pg/sqlc/address_types.sql.go diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/addresses.sql.go rename to wallet/internal/sql/pg/sqlc/addresses.sql.go diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/sql/pg/sqlc/blocks.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/blocks.sql.go rename to wallet/internal/sql/pg/sqlc/blocks.sql.go diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/sql/pg/sqlc/db.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/db.go rename to wallet/internal/sql/pg/sqlc/db.go diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/key_scopes.sql.go rename to wallet/internal/sql/pg/sqlc/key_scopes.sql.go diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/sql/pg/sqlc/models.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/models.go rename to wallet/internal/sql/pg/sqlc/models.go diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/sql/pg/sqlc/querier.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/querier.go rename to wallet/internal/sql/pg/sqlc/querier.go diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/transactions.sql.go rename to wallet/internal/sql/pg/sqlc/transactions.sql.go diff --git a/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/tx_replacements.sql.go rename to wallet/internal/sql/pg/sqlc/tx_replacements.sql.go diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/utxo_leases.sql.go rename to wallet/internal/sql/pg/sqlc/utxo_leases.sql.go diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/sql/pg/sqlc/utxos.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/utxos.sql.go rename to wallet/internal/sql/pg/sqlc/utxos.sql.go diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/sql/pg/sqlc/wallets.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/wallets.sql.go rename to wallet/internal/sql/pg/sqlc/wallets.sql.go From 9d0f6ca93bc32deb760725163c7f9e7f201cf8d9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:54:26 +0800 Subject: [PATCH 226/695] db/sqlite: drop private backend prefixes Rename the sqlite package's private helpers, adapters, and row conversion types so they rely on the package namespace instead of repeating sqlite in every identifier. This keeps the backend-specific package easier to scan now that the split already provides the backend context. --- wallet/internal/db/sqlite/accounts.go | 78 ++++++------ wallet/internal/db/sqlite/address_types.go | 8 +- wallet/internal/db/sqlite/addresses.go | 116 +++++++++--------- .../internal/db/sqlite/backend_error_test.go | 18 +-- .../internal/db/sqlite/backend_rows_test.go | 36 +++--- wallet/internal/db/sqlite/block.go | 12 +- wallet/internal/db/sqlite/testhelpers_test.go | 4 +- wallet/internal/db/sqlite/txstore_createtx.go | 76 ++++++------ wallet/internal/db/sqlite/txstore_deletetx.go | 30 ++--- wallet/internal/db/sqlite/txstore_gettx.go | 8 +- .../db/sqlite/txstore_invalidateunmined.go | 16 +-- wallet/internal/db/sqlite/txstore_listtxns.go | 16 +-- wallet/internal/db/sqlite/txstore_rollback.go | 28 ++--- wallet/internal/db/sqlite/txstore_updatetx.go | 18 +-- wallet/internal/db/sqlite/utxo_extra_test.go | 4 +- .../internal/db/sqlite/utxostore_getutxo.go | 6 +- .../db/sqlite/utxostore_leaseoutput.go | 12 +- .../internal/db/sqlite/utxostore_listutxos.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 14 +-- wallet/internal/db/sqlite/wallet.go | 40 +++--- 20 files changed, 271 insertions(+), 271 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 66c96b48b5..fb9b9ae041 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -17,7 +17,7 @@ var _ db.AccountStore = (*SqliteStore)(nil) func (s *SqliteStore) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := sqliteAccountGetQueries{q: s.queries} + getQueries := accountGetQueries{q: s.queries} return db.GetAccountByQuery( ctx, query, getQueries.byNumber, getQueries.byName, @@ -29,7 +29,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, func (s *SqliteStore) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := sqliteAccountListQueries{q: s.queries} + listQueries := accountListQueries{q: s.queries} return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -41,7 +41,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, func (s *SqliteStore) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := sqliteAccountRenameQueries{q: s.queries} + renameQueries := accountRenameQueries{q: s.queries} return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -62,7 +62,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - scopeID, err := sqliteEnsureKeyScope( + scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) if err != nil { @@ -106,9 +106,9 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, return info, nil } -// sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing +// ensureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. -func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( @@ -151,18 +151,18 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { - return sqliteEnsureKeyScope( + return ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) }, qtx.CreateImportedAccount, - sqliteBuildCreateImportedAccountArgs(params), + buildCreateImportedAccountArgs(params), func(row sqlcsqlite.CreateImportedAccountRow) int64 { return row.ID }, - qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), + qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { - return sqliteGetAccountProps(ctx, qtx, accountID) + return getAccountProps(ctx, qtx, accountID) }, ) @@ -175,9 +175,9 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, return props, nil } -// sqliteBuildCreateImportedAccountArgs returns a function that builds the +// buildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for SQLite. -func sqliteBuildCreateImportedAccountArgs( +func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, ) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { @@ -198,9 +198,9 @@ func sqliteBuildCreateImportedAccountArgs( } } -// sqliteBuildCreateAccountSecretArgs returns a function that builds the +// buildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for SQLite. -func sqliteBuildCreateAccountSecretArgs( +func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, ) func(int64) sqlcsqlite.CreateAccountSecretParams { @@ -212,9 +212,9 @@ func sqliteBuildCreateAccountSecretArgs( } } -// sqliteGetAccountProps fetches full account properties from the database and +// getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, +func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -242,10 +242,10 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, }) } -// sqliteAccountInfoRow is a type constraint for SQLite account info row types +// accountInfoRow is a type constraint for SQLite account info row types // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. -type sqliteAccountInfoRow interface { +type accountInfoRow interface { sqlcsqlite.GetAccountByScopeAndNameRow | sqlcsqlite.GetAccountByScopeAndNumberRow | sqlcsqlite.GetAccountByWalletScopeAndNameRow | @@ -255,10 +255,10 @@ type sqliteAccountInfoRow interface { sqlcsqlite.ListAccountsByWalletAndNameRow } -// sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo -// struct. It uses type conversion since all sqliteAccountInfoRow types have +// accountRowToInfo converts a SQLite account row to an AccountInfo +// struct. It uses type conversion since all accountInfoRow types have // identical fields. -func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, +func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have @@ -280,13 +280,13 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, }) } -// sqliteAccountListQueries groups SQLite account listing query methods. -type sqliteAccountListQueries struct { +// accountListQueries groups SQLite account listing query methods. +type accountListQueries struct { q *sqlcsqlite.Queries } // byScope lists accounts filtered by wallet ID and key scope. -func (s sqliteAccountListQueries) byScope(ctx context.Context, +func (s accountListQueries) byScope(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -295,12 +295,12 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - }, sqliteAccountRowToInfo, + }, accountRowToInfo, ) } // byName lists accounts filtered by wallet ID and account name. -func (s sqliteAccountListQueries) byName(ctx context.Context, +func (s accountListQueries) byName(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -308,27 +308,27 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, - }, sqliteAccountRowToInfo, + }, accountRowToInfo, ) } // all lists all accounts for a wallet. -func (s sqliteAccountListQueries) all(ctx context.Context, +func (s accountListQueries) all(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), - sqliteAccountRowToInfo, + accountRowToInfo, ) } -// sqliteAccountGetQueries groups SQLite account retrieval query methods. -type sqliteAccountGetQueries struct { +// accountGetQueries groups SQLite account retrieval query methods. +type accountGetQueries struct { q *sqlcsqlite.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. -func (s sqliteAccountGetQueries) byNumber(ctx context.Context, +func (s accountGetQueries) byNumber(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -338,12 +338,12 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), - }, query, sqliteAccountRowToInfo, + }, query, accountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. -func (s sqliteAccountGetQueries) byName(ctx context.Context, +func (s accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, @@ -352,18 +352,18 @@ func (s sqliteAccountGetQueries) byName(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountName: *query.Name, - }, query, sqliteAccountRowToInfo, + }, query, accountRowToInfo, ) } -// sqliteAccountRenameQueries groups SQLite account rename query methods. -type sqliteAccountRenameQueries struct { +// accountRenameQueries groups SQLite account rename query methods. +type accountRenameQueries struct { q *sqlcsqlite.Queries } // byNumber renames an account identified by wallet ID, scope, and account // number. -func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, +func (s accountRenameQueries) byNumber(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( @@ -380,7 +380,7 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. -func (s sqliteAccountRenameQueries) byName(ctx context.Context, +func (s accountRenameQueries) byName(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index bdea9bb339..ae2e39ed4e 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -7,9 +7,9 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// sqliteAddressTypeRowToInfo converts a SQLite address type row to an +// addressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, +func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) @@ -29,7 +29,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, ) } @@ -40,6 +40,6 @@ func (s *SqliteStore) GetAddressType(ctx context.Context, return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, - sqliteAddressTypeRowToInfo, + addressTypeRowToInfo, ) } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index c98845a245..a5c7a5a9fb 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -27,7 +27,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, - }, sqliteAddressRowToInfo, + }, addressRowToInfo, ) } @@ -38,7 +38,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, func (s *SqliteStore) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - items, err := sqliteListAddressesByAccount(ctx, s.queries, query) + items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -68,7 +68,7 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, - sqliteAddressSecretRowToSecret, + addressSecretRowToSecret, ) } @@ -83,14 +83,14 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, sqlcsqlite.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - GetAccount: sqliteGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, - GetAccountID: newDerivedAddressGetAccountIDSQLite, - GetExtIndex: newDerivedAddressGetExtIndexSQLite, - GetIntIndex: newDerivedAddressGetIntIndexSQLite, - CreateAddr: newDerivedAddressCreateAddrSQLite, - RowID: newDerivedAddressRowIDSQLite, - RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + GetAccountID: derivedAddressGetAccountID, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, } return db.NewDerivedAddressWithTx( @@ -109,22 +109,22 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - GetAccount: sqliteGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: newImportedAddressGetAccountIDSQLite, - CreateAddr: sqliteCreateImportedAddress, - CreateParams: createImportedAddressParamsSQLite, - InsertSecret: sqliteInsertAddressSecret, - SecretParams: insertAddressSecretParamsSQLite, - RowID: importedAddressRowIDSQLite, - RowCreatedAt: importedAddressRowCreatedAtSQLite, + GetAccountID: importedAddressGetAccountID, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, } return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } -// sqliteGetAccountFromKey returns a helper to look up accounts by key. -func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, +// getAccountFromKey returns a helper to look up accounts by key. +func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, @@ -142,29 +142,29 @@ func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, } } -// newDerivedAddressGetAccountIDSQLite extracts the account ID from a row. -func newDerivedAddressGetAccountIDSQLite( +// derivedAddressGetAccountID extracts the account ID from a row. +func derivedAddressGetAccountID( row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// newDerivedAddressGetExtIndexSQLite returns the external index query. -func newDerivedAddressGetExtIndexSQLite( +// derivedAddressGetExtIndex returns the external index query. +func derivedAddressGetExtIndex( qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } -// newDerivedAddressGetIntIndexSQLite returns the internal index query. -func newDerivedAddressGetIntIndexSQLite( +// derivedAddressGetIntIndex returns the internal index query. +func derivedAddressGetIntIndex( qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } -// newDerivedAddressCreateAddrSQLite returns the derived address insert helper. -func newDerivedAddressCreateAddrSQLite( +// derivedAddressCreateAddr returns the derived address insert helper. +func derivedAddressCreateAddr( qtx *sqlcsqlite.Queries, ) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( sqlcsqlite.CreateDerivedAddressRow, error, @@ -193,44 +193,44 @@ func newDerivedAddressCreateAddrSQLite( } } -// newDerivedAddressRowIDSQLite returns the created address ID. -func newDerivedAddressRowIDSQLite( +// derivedAddressRowID returns the created address ID. +func derivedAddressRowID( row sqlcsqlite.CreateDerivedAddressRow) int64 { return row.ID } -// newDerivedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. -func newDerivedAddressRowCreatedAtSQLite( +// derivedAddressRowCreatedAt returns the CreatedAt timestamp. +func derivedAddressRowCreatedAt( row sqlcsqlite.CreateDerivedAddressRow) time.Time { return row.CreatedAt } -// newImportedAddressGetAccountIDSQLite extracts the account ID from a row. -func newImportedAddressGetAccountIDSQLite( +// importedAddressGetAccountID extracts the account ID from a row. +func importedAddressGetAccountID( row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// sqliteCreateImportedAddress returns the imported address insert helper. -func sqliteCreateImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, +// createImportedAddress returns the imported address insert helper. +func createImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, sqlcsqlite.CreateImportedAddressParams) ( sqlcsqlite.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } -// sqliteInsertAddressSecret returns the secret insert helper. -func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, +// insertAddressSecret returns the secret insert helper. +func insertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, sqlcsqlite.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } -// createImportedAddressParamsSQLite maps imported params to sqlc params. -func createImportedAddressParamsSQLite(accountID int64, +// createImportedAddressParams maps imported params to sqlc params. +func createImportedAddressParams(accountID int64, params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { return sqlcsqlite.CreateImportedAddressParams{ @@ -241,20 +241,20 @@ func createImportedAddressParamsSQLite(accountID int64, } } -// importedAddressRowIDSQLite returns the created address ID. -func importedAddressRowIDSQLite(row sqlcsqlite.CreateImportedAddressRow) int64 { +// importedAddressRowID returns the created address ID. +func importedAddressRowID(row sqlcsqlite.CreateImportedAddressRow) int64 { return row.ID } -// importedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. -func importedAddressRowCreatedAtSQLite( +// importedAddressRowCreatedAt returns the CreatedAt timestamp. +func importedAddressRowCreatedAt( row sqlcsqlite.CreateImportedAddressRow) time.Time { return row.CreatedAt } -// insertAddressSecretParamsSQLite maps imported params to secret params. -func insertAddressSecretParamsSQLite(addressID int64, +// insertAddressSecretParams maps imported params to secret params. +func insertAddressSecretParams(addressID int64, params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { return sqlcsqlite.InsertAddressSecretParams{ @@ -264,9 +264,9 @@ func insertAddressSecretParamsSQLite(addressID int64, } } -// sqliteAddressSecretRowToSecret converts a SQLite address secret row to an +// addressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. -func sqliteAddressSecretRowToSecret( +func addressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ @@ -276,17 +276,17 @@ func sqliteAddressSecretRowToSecret( }) } -// sqliteAddressInfoRow is a type constraint union that represents all SQLite +// addressInfoRow is a type constraint union that represents all SQLite // address row types that share the same field structure. This enables a // single generic conversion function to handle all address query result types. -type sqliteAddressInfoRow interface { +type addressInfoRow interface { sqlcsqlite.GetAddressByScriptPubKeyRow | sqlcsqlite.ListAddressesByAccountRow } -// sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo +// addressRowToInfo converts a SQLite address row to an AddressInfo // struct. -func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, +func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. @@ -314,13 +314,13 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, return info, nil } -// sqliteListAddressesByAccount lists addresses filtered by wallet ID, key +// listAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. -func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( - ctx, sqliteBuildAddressPageParams(query), + ctx, buildAddressPageParams(query), ) if err != nil { return nil, fmt.Errorf("list addresses by account: %w", err) @@ -328,7 +328,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, items := make([]db.AddressInfo, len(rows)) for i, row := range rows { - item, err := sqliteAddressRowToInfo(row) + item, err := addressRowToInfo(row) if err != nil { return nil, fmt.Errorf("list addresses by account: map address row: %w", @@ -341,9 +341,9 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, return items, nil } -// sqliteBuildAddressPageParams translates a ListAddresses query to +// buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. -func sqliteBuildAddressPageParams( +func buildAddressPageParams( q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { params := sqlcsqlite.ListAddressesByAccountParams{ diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index 872411ea98..7921ebc41a 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -17,8 +17,8 @@ func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - deleteOps := sqliteDeleteTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + deleteOps := deleteTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") @@ -46,13 +46,13 @@ func TestTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{qtx: qtx}, + createOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } - invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - updateOps := &sqliteUpdateTxOps{qtx: qtx} - releaseOps := sqliteReleaseOutputOps{qtx: qtx} + invalidateOps := invalidateUnminedTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} + updateOps := &updateTxOps{qtx: qtx} + releaseOps := releaseOutputOps{qtx: qtx} err := createOps.MarkTxnsReplaced(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns replaced") @@ -60,7 +60,7 @@ func TestTxStoreOpsWrapBackendErrors(t *testing.T) { err = createOps.InsertReplacementEdges(t.Context(), 1, []int64{2}, 3) require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentSqlite(t.Context(), qtx, db.CreateTxParams{ + err = markInputsSpent(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index e727492616..19bd983256 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -19,10 +19,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - loadOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + loadOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + row: newRow(t, "SELECT 1 FROM missing_table"), }), }, } @@ -31,10 +31,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { require.ErrorContains(t, err, "get tx metadata") block := testBlock(8) - confirmOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + confirmOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow( + row: newRow( t, "SELECT ?, ?, ?", int64(block.Height), @@ -51,10 +51,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { }, db.CreateTxExistingTarget{}) require.ErrorIs(t, err, db.ErrTxNotFound) - prepareOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + prepareOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + row: newRow(t, "SELECT 1 FROM missing_table"), }), }, } @@ -63,10 +63,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { }) require.ErrorContains(t, err, "get block by height") - conflictOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + conflictOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT ?", int64(5)), + row: newRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, @@ -79,8 +79,8 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() - ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + ops := &releaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newRow(t, "SELECT 1 FROM missing_table"), })} _, err := ops.LookupUtxoID(context.Background(), db.ReleaseOutputParams{ @@ -99,15 +99,15 @@ func TestUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + loadOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newRow(t, "SELECT 1 FROM missing_table"), })} - stateOps := &sqliteUpdateTxOps{ + stateOps := &updateTxOps{ qtx: sqlcsqlite.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt64{}, status: int64(db.TxStatusPublished), } - labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 5306caeeca..2fc2b7fedb 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -11,9 +11,9 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// buildSqliteBlock constructs a Block from the given SQLite block +// buildBlock constructs a Block from the given SQLite block // fields. -func buildSqliteBlock(height sql.NullInt64, hash []byte, +func buildBlock(height sql.NullInt64, hash []byte, timestamp sql.NullInt64) (*db.Block, error) { height32, err := db.Int64ToUint32(height.Int64) @@ -24,9 +24,9 @@ func buildSqliteBlock(height sql.NullInt64, hash []byte, return db.BuildBlock(hash, height32, timestamp.Int64) } -// ensureBlockExistsSqlite ensures that a block exists in the database. If it +// ensureBlockExists ensures that a block exists in the database. If it // doesn't exist, it inserts it. -func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, block *db.Block) error { height := int64(block.Height) @@ -45,9 +45,9 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// requireBlockMatchesSqlite loads the shared block row for the provided height +// requireBlockMatches loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. -func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlcsqlite.Queries, block *db.Block) (int64, error) { height := int64(block.Height) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go index 9562137bc0..09e252ff8c 100644 --- a/wallet/internal/db/sqlite/testhelpers_test.go +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -108,8 +108,8 @@ func (r rowDBTX) QueryRowContext(context.Context, string, return &sql.Row{} } -// newSQLiteRow creates a query row backed by an in-memory sqlite database. -func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { +// newRow creates a query row backed by an in-memory sqlite database. +func newRow(t *testing.T, query string, args ...any) *sql.Row { t.Helper() dbConn, err := sql.Open("sqlite", ":memory:") diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 26c94f5235..105a801002 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -30,25 +30,25 @@ func (s *SqliteStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + return db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, }, }) }) } -// sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. -type sqliteCreateTxOps struct { - sqliteInvalidateUnminedTxOps +// createTxOps adapts sqlite sqlc queries to the shared CreateTx flow. +type createTxOps struct { + invalidateUnminedTxOps blockHeight sql.NullInt64 } -var _ db.CreateTxOps = (*sqliteCreateTxOps)(nil) +var _ db.CreateTxOps = (*createTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, +func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -80,11 +80,11 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, } // ConfirmExisting promotes one existing unmined row to its confirmed state. -func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, +func (o *createTxOps) ConfirmExisting(ctx context.Context, req db.CreateTxRequest, _ db.CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) + blockHeight, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -110,7 +110,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the sqlite // block-height value that the later Insert query will store. -func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, +func (o *createTxOps) PrepareBlock(ctx context.Context, req db.CreateTxRequest) error { o.blockHeight = sql.NullInt64{} @@ -119,7 +119,7 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, return nil } - height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) + height, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -131,10 +131,10 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, +func (o *createTxOps) ListConflictTxns(ctx context.Context, req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { - rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) + rootIDs, err := collectConflictRootIDs(ctx, o.qtx, req) if err != nil { return nil, nil, err } @@ -148,12 +148,12 @@ func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, return nil, nil, fmt.Errorf("list unmined txns: %w", err) } - return buildSqliteConflictRoots(rows, rootIDs) + return buildConflictRoots(rows, rootIDs) } -// collectSqliteConflictRootIDs returns the active unmined spender row +// collectConflictRootIDs returns the active unmined spender row // IDs that currently own any wallet-controlled input spent by the incoming tx. -func collectSqliteConflictRootIDs(ctx context.Context, +func collectConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { @@ -189,9 +189,9 @@ func collectSqliteConflictRootIDs(ctx context.Context, return rootIDs, nil } -// buildSqliteConflictRoots maps the selected unmined rows into ordered root IDs +// buildConflictRoots maps the selected unmined rows into ordered root IDs // and the matching root hashes used for descendant discovery. -func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -216,7 +216,7 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, } // Insert stores one new sqlite transaction row for CreateTx. -func (o *sqliteCreateTxOps) Insert(ctx context.Context, +func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( @@ -240,22 +240,22 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, } // InsertCredits stores any wallet-owned outputs created by the transaction. -func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, +func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. -func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, +func (o *createTxOps) MarkInputsSpent(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) + return markInputsSpent(ctx, o.qtx, req.Params, txID) } // MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *sqliteCreateTxOps) MarkTxnsReplaced( +func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -274,7 +274,7 @@ func (o *sqliteCreateTxOps) MarkTxnsReplaced( // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *sqliteCreateTxOps) InsertReplacementEdges( +func (o *createTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -295,13 +295,13 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( return nil } -// insertCreditsSqlite inserts one wallet-owned UTXO row for each credited +// insertCredits inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. -func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExistsSqlite( + creditExists, err := creditExists( ctx, qtx, params.WalletID, params.Tx.TxHash(), index, ) if err != nil { @@ -344,9 +344,9 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// creditExistsSqlite reports whether the wallet already has a UTXO row for the +// creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { _, err := qtx.GetUtxoSpendByOutpoint( @@ -368,7 +368,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return true, nil } -// markInputsSpentSqlite attaches wallet-owned outpoints spent by the stored +// markInputsSpent attaches wallet-owned outpoints spent by the stored // transaction to its row ID and input indexes. // // If another wallet transaction already owns the spend edge for a @@ -376,7 +376,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -399,7 +399,7 @@ func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, } if rowsAffected == 0 { - err = ensureSpendConflictSqlite( + err = ensureSpendConflict( ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, int64(txIn.PreviousOutPoint.Index), txID, ) @@ -412,11 +412,11 @@ func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// ensureSpendConflictSqlite reports ErrTxInputConflict when the referenced +// ensureSpendConflict reports ErrTxInputConflict when the referenced // outpoint is wallet-owned, still eligible for spending, and already attached // to another transaction. If the wallet owns the parent output but that parent // is already invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflictSqlite(ctx context.Context, +func ensureSpendConflict(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64, txID int64) error { @@ -429,7 +429,7 @@ func ensureSpendConflictSqlite(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return ensureWalletParentValidSqlite( + return ensureWalletParentValid( ctx, qtx, walletID, txHash, outputIndex, ) } @@ -444,10 +444,10 @@ func ensureSpendConflictSqlite(ctx context.Context, return nil } -// ensureWalletParentValidSqlite reports ErrTxInputInvalidParent when the +// ensureWalletParentValid reports ErrTxInputInvalidParent when the // wallet owns the referenced outpoint but its parent transaction is already // invalid. -func ensureWalletParentValidSqlite(ctx context.Context, +func ensureWalletParentValid(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64) error { diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 06032ce826..b412d9798d 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -22,23 +22,23 @@ func (s *SqliteStore) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } -// sqliteDeleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. -type sqliteDeleteTxOps struct { +// deleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. +type deleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ db.DeleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*deleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, +func (o deleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { - meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) + meta, err := getDeleteTxMeta(ctx, o.qtx, walletID, txHash) if err != nil { return 0, err } @@ -48,15 +48,15 @@ func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, // EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o sqliteDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, +func (o deleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { - return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) + return ensureDeleteLeaf(ctx, o.qtx, walletID, txHash, txID) } // ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, +func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -75,7 +75,7 @@ func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, +func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -94,7 +94,7 @@ func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, // DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, +func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -111,10 +111,10 @@ func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, return rows, nil } -// ensureDeleteLeafSqlite rejects DeleteTx requests for transactions that still +// ensureDeleteLeaf rejects DeleteTx requests for transactions that still // have direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -151,9 +151,9 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// getDeleteTxMetaSqlite loads the transaction metadata DeleteTx needs and +// getDeleteTxMeta loads the transaction metadata DeleteTx needs and // enforces the unmined precondition up front. -func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash) ( sqlcsqlite.GetTransactionMetaByHashRow, error) { diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index fc8048fa28..56122872f4 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -32,15 +32,15 @@ func (s *SqliteStore) GetTx(ctx context.Context, return nil, fmt.Errorf("get tx: %w", err) } - return txInfoFromSqliteRow( + return txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, ) } -// txInfoFromSqliteRow converts one normalized sqlite query row into the public +// txInfoFromRow converts one normalized sqlite query row into the public // TxInfo shape. -func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, +func txInfoFromRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, status int64, label string) (*db.TxInfo, error) { @@ -52,7 +52,7 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, // Unmined rows legitimately have no block metadata, so only build the Block // shape when the row still carries a valid height. if blockHeight.Valid { - block, err = buildSqliteBlock(blockHeight, blockHash, blockTimestamp) + block, err = buildBlock(blockHeight, blockHash, blockTimestamp) if err != nil { return nil, err } diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 5a0c0bea12..1671b9d150 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -18,22 +18,22 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.InvalidateUnminedTxWithOps( - ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, + ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) }) } -// sqliteInvalidateUnminedTxOps adapts sqlite sqlc queries to the shared +// invalidateUnminedTxOps adapts sqlite sqlc queries to the shared // InvalidateUnminedTx workflow. -type sqliteInvalidateUnminedTxOps struct { +type invalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ db.InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, +func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { @@ -70,7 +70,7 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( +func (o invalidateUnminedTxOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -89,7 +89,7 @@ func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( // ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, +func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -110,7 +110,7 @@ func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, // MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( +func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index 673f953171..f656805c5c 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -19,17 +19,17 @@ func (s *SqliteStore) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { - return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) + return s.listTxnsWithoutBlock(ctx, query.WalletID) } - return s.listConfirmedTxnsSqlite(ctx, query) + return s.listConfirmedTxns(ctx, query) } -// listTxnsWithoutBlockSqlite loads every transaction row that currently has no +// listTxnsWithoutBlock loads every transaction row that currently has no // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, +func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -39,7 +39,7 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - info, err := txInfoFromSqliteRow( + info, err := txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, ) @@ -53,9 +53,9 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, return infos, nil } -// listConfirmedTxnsSqlite loads the confirmed height-range view used by +// listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. -func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, +func (s *SqliteStore) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( @@ -71,7 +71,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - block, err := buildSqliteBlock( + block, err := buildBlock( row.BlockHeight, row.BlockHash, sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index a170faf82a..3495c69eb1 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -18,21 +18,21 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.RollbackToBlockWithOps(ctx, height, - sqliteRollbackToBlockOps{qtx: qtx}) + rollbackToBlockOps{qtx: qtx}) }) } -// sqliteRollbackToBlockOps adapts sqlite sqlc queries to the shared rollback +// rollbackToBlockOps adapts sqlite sqlc queries to the shared rollback // sequence. -type sqliteRollbackToBlockOps struct { +type rollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ db.RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, +func (o rollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) @@ -40,12 +40,12 @@ func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, return nil, fmt.Errorf("query rollback coinbase roots: %w", err) } - return groupRollbackCoinbaseRootsSqlite(rows) + return groupRollbackCoinbaseRoots(rows) } // RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( +func (o rollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { newHeight := sql.NullInt64{} @@ -68,7 +68,7 @@ func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( // DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( +func (o rollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) @@ -81,7 +81,7 @@ func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( // MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( +func (o rollbackToBlockOps) MarkTxRootsOrphaned( ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { @@ -112,7 +112,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( +func (o rollbackToBlockOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -131,7 +131,7 @@ func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o sqliteRollbackToBlockOps) ClearDescendantSpends( +func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -152,7 +152,7 @@ func (o sqliteRollbackToBlockOps) ClearDescendantSpends( // MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( +func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -169,9 +169,9 @@ func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( return nil } -// groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by +// groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRootsSqlite( +func groupRollbackCoinbaseRoots( rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index af5c597a4a..5c4dbad320 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -21,12 +21,12 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } -// sqliteUpdateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. -type sqliteUpdateTxOps struct { +// updateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. +type updateTxOps struct { // qtx is the transaction-scoped sqlite query set used by UpdateTx. qtx *sqlcsqlite.Queries @@ -39,11 +39,11 @@ type sqliteUpdateTxOps struct { status int64 } -var _ db.UpdateTxOps = (*sqliteUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*updateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, +func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -66,13 +66,13 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, // PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. -func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, +func (o *updateTxOps) PrepareState(ctx context.Context, state db.UpdateTxState) error { blockHeight := sql.NullInt64{} if state.Block != nil { - height, err := requireBlockMatchesSqlite(ctx, o.qtx, state.Block) + height, err := requireBlockMatches(ctx, o.qtx, state.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -87,7 +87,7 @@ func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, } // UpdateLabel writes one user-visible label change. -func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( @@ -111,7 +111,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( diff --git a/wallet/internal/db/sqlite/utxo_extra_test.go b/wallet/internal/db/sqlite/utxo_extra_test.go index 53ab180ff6..e647d2836c 100644 --- a/wallet/internal/db/sqlite/utxo_extra_test.go +++ b/wallet/internal/db/sqlite/utxo_extra_test.go @@ -14,7 +14,7 @@ func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { t.Parallel() hash := chainhash.Hash{13} - _, err := utxoInfoFromSqliteRow( + _, err := utxoInfoFromRow( hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, sql.NullInt64{}, ) @@ -26,7 +26,7 @@ func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { t.Parallel() hash := chainhash.Hash{14} - _, err := utxoInfoFromSqliteRow( + _, err := utxoInfoFromRow( hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, sql.NullInt64{Int64: -1, Valid: true}, ) diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index a63615b790..f0eb24dbdb 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -34,15 +34,15 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, return nil, fmt.Errorf("get utxo: %w", err) } - return utxoInfoFromSqliteRow( + return utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) } -// utxoInfoFromSqliteRow converts one normalized sqlite query row into the +// utxoInfoFromRow converts one normalized sqlite query row into the // public UtxoInfo shape. -func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, +func utxoInfoFromRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt64) (*db.UtxoInfo, error) { diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 177644f847..19849dc232 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -23,7 +23,7 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( - ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, + ctx, params, &leaseOutputOps{qtx: qtx}, ) if err != nil { return err @@ -40,17 +40,17 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, return lease, nil } -// sqliteLeaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput +// leaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput // workflow. -type sqliteLeaseOutputOps struct { +type leaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ db.LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) // Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. -func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, +func (o *leaseOutputOps) Acquire(ctx context.Context, params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -77,7 +77,7 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, +func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index dd47d02cfb..433c4416fd 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -27,7 +27,7 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { - utxo, err := utxoInfoFromSqliteRow( + utxo, err := utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index dd716d9228..a7f9a0fc48 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -21,22 +21,22 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.ReleaseOutputWithOps( - ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, + ctx, params, &releaseOutputOps{qtx: qtx}, ) }) } -// sqliteReleaseOutputOps adapts sqlite sqlc queries to the shared +// releaseOutputOps adapts sqlite sqlc queries to the shared // ReleaseOutput workflow. -type sqliteReleaseOutputOps struct { +type releaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ db.ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. -func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, +func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( @@ -59,7 +59,7 @@ func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, // Release attempts to delete the sqlite lease row for the provided UTXO ID and // lock ID. -func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -78,7 +78,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, // ActiveLockID returns the currently active sqlite lease lock ID for the // provided UTXO ID. -func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, +func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 8e687e253b..51f582534f 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -84,7 +84,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, ) } - info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ + info, err = buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -127,7 +127,7 @@ func (s *SqliteStore) GetWallet(ctx context.Context, return nil, fmt.Errorf("get wallet: %w", err) } - return buildSqliteWalletInfo(sqliteWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -148,7 +148,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( - ctx, sqliteListWalletsParams(query.Page), + ctx, listWalletsParams(query.Page), ) if err != nil { return page.Result[db.WalletInfo, uint32]{}, @@ -157,7 +157,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, items := make([]db.WalletInfo, len(rows)) for i, row := range rows { - item, errMap := sqliteListWalletRowToInfo(row) + item, errMap := listWalletRowToInfo(row) if errMap != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) @@ -195,7 +195,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { - err := ensureBlockExistsSqlite( + err := ensureBlockExists( ctx, qtx, params.SyncedTo, ) if err != nil { @@ -205,7 +205,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, } if params.BirthdayBlock != nil { - err := ensureBlockExistsSqlite( + err := ensureBlockExists( ctx, qtx, params.BirthdayBlock, ) if err != nil { @@ -214,7 +214,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, } } - syncParams := buildUpdateSyncParamsSqlite(params) + syncParams := buildUpdateSyncParams(params) rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) if err != nil { @@ -281,9 +281,9 @@ func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, return nil } -// sqliteWalletRowParams holds the parameters needed to build a +// walletRowParams holds the parameters needed to build a // WalletInfo from a wallet row. -type sqliteWalletRowParams struct { +type walletRowParams struct { id int64 name string isImported bool @@ -298,12 +298,12 @@ type sqliteWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } -// sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func sqliteListWalletRowToInfo( +func listWalletRowToInfo( row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { - return buildSqliteWalletInfo(sqliteWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -319,9 +319,9 @@ func sqliteListWalletRowToInfo( }) } -// sqliteListWalletsParams translates a page request to ListWallets query +// listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. -func sqliteListWalletsParams( +func listWalletsParams( req page.Request[uint32]) sqlcsqlite.ListWalletsParams { params := sqlcsqlite.ListWalletsParams{ @@ -335,9 +335,9 @@ func sqliteListWalletsParams( return params } -// buildSqliteWalletInfo constructs a WalletInfo from the given wallet +// buildWalletInfo constructs a WalletInfo from the given wallet // row parameters. -func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { +func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err @@ -361,7 +361,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { } if row.syncedHeight.Valid { - block, err := buildSqliteBlock( + block, err := buildBlock( row.syncedHeight, row.syncedBlockHash, row.syncedBlockTimestamp, @@ -374,7 +374,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { } if row.birthdayHeight.Valid { - block, err := buildSqliteBlock( + block, err := buildBlock( row.birthdayHeight, row.birthdayBlockHash, row.birthdayBlockTimestamp, @@ -389,9 +389,9 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { return info, nil } -// buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from +// buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsSqlite( +func buildUpdateSyncParams( params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ From a8ae9f5c41d32b1d094fc9842fba3f21e9c21b00 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:57:20 +0800 Subject: [PATCH 227/695] db/pg: drop private backend prefixes Rename the pg package's private helpers, adapters, and row conversion types so they rely on the package namespace instead of repeating pg in every identifier. This keeps the backend-specific package easier to scan now that the split already provides the backend context. --- wallet/internal/db/pg/accounts.go | 78 ++++++------ wallet/internal/db/pg/address_types.go | 8 +- wallet/internal/db/pg/addresses.go | 116 +++++++++--------- wallet/internal/db/pg/backend_error_test.go | 32 ++--- wallet/internal/db/pg/backend_rows_test.go | 18 +-- wallet/internal/db/pg/block.go | 12 +- wallet/internal/db/pg/txstore_createtx.go | 76 ++++++------ wallet/internal/db/pg/txstore_deletetx.go | 30 ++--- wallet/internal/db/pg/txstore_gettx.go | 8 +- .../db/pg/txstore_invalidateunmined.go | 16 +-- wallet/internal/db/pg/txstore_listtxns.go | 16 +-- wallet/internal/db/pg/txstore_rollback.go | 28 ++--- wallet/internal/db/pg/txstore_updatetx.go | 18 +-- wallet/internal/db/pg/utxo_extra_test.go | 4 +- wallet/internal/db/pg/utxostore_getutxo.go | 6 +- .../internal/db/pg/utxostore_leaseoutput.go | 12 +- wallet/internal/db/pg/utxostore_listutxos.go | 8 +- .../internal/db/pg/utxostore_releaseoutput.go | 14 +-- wallet/internal/db/pg/wallet.go | 40 +++--- 19 files changed, 270 insertions(+), 270 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 2ee775c5c1..cda543c54f 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -17,7 +17,7 @@ var _ db.AccountStore = (*PostgresStore)(nil) func (s *PostgresStore) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := pgAccountGetQueries{q: s.queries} + getQueries := accountGetQueries{q: s.queries} return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } @@ -27,7 +27,7 @@ func (s *PostgresStore) GetAccount(ctx context.Context, func (s *PostgresStore) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := pgAccountListQueries{q: s.queries} + listQueries := accountListQueries{q: s.queries} return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -39,7 +39,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, func (s *PostgresStore) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := pgAccountRenameQueries{q: s.queries} + renameQueries := accountRenameQueries{q: s.queries} return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -60,7 +60,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - scopeID, err := pgEnsureKeyScope( + scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) if err != nil { @@ -128,13 +128,13 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { - return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) + return ensureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, - pgBuildCreateImportedAccountArgs(params), + buildCreateImportedAccountArgs(params), func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, - qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), + qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { - return pgGetAccountProps(ctx, qtx, accountID) + return getAccountProps(ctx, qtx, accountID) }, ) @@ -147,9 +147,9 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, return props, nil } -// pgBuildCreateImportedAccountArgs returns a function that builds the +// buildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for PostgreSQL. -func pgBuildCreateImportedAccountArgs( +func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, ) func(int64, bool) sqlcpg.CreateImportedAccountParams { @@ -170,9 +170,9 @@ func pgBuildCreateImportedAccountArgs( } } -// pgBuildCreateAccountSecretArgs returns a function that builds the +// buildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for PostgreSQL. -func pgBuildCreateAccountSecretArgs( +func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, ) func(int64) sqlcpg.CreateAccountSecretParams { @@ -184,9 +184,9 @@ func pgBuildCreateAccountSecretArgs( } } -// pgGetAccountProps fetches full account properties from the database and +// getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, +func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -214,9 +214,9 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, }) } -// pgEnsureKeyScope retrieves an existing key scope or creates it if missing for +// ensureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. -func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, +func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( @@ -246,10 +246,10 @@ func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ) } -// pgAccountInfoRow is a type constraint for PostgreSQL account info row types +// accountInfoRow is a type constraint for PostgreSQL account info row types // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. -type pgAccountInfoRow interface { +type accountInfoRow interface { sqlcpg.GetAccountByScopeAndNameRow | sqlcpg.GetAccountByScopeAndNumberRow | sqlcpg.GetAccountByWalletScopeAndNameRow | @@ -259,10 +259,10 @@ type pgAccountInfoRow interface { sqlcpg.ListAccountsByWalletAndNameRow } -// pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo -// struct. It uses type conversion since all pgAccountInfoRow types have +// accountRowToInfo converts a PostgreSQL account row to an AccountInfo +// struct. It uses type conversion since all accountInfoRow types have // identical fields. -func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { +func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) @@ -282,13 +282,13 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { }) } -// pgAccountListQueries groups PostgreSQL account listing query methods. -type pgAccountListQueries struct { +// accountListQueries groups PostgreSQL account listing query methods. +type accountListQueries struct { q *sqlcpg.Queries } // byScope lists accounts filtered by wallet ID and key scope. -func (p pgAccountListQueries) byScope(ctx context.Context, +func (p accountListQueries) byScope(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -297,12 +297,12 @@ func (p pgAccountListQueries) byScope(ctx context.Context, WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - }, pgAccountRowToInfo, + }, accountRowToInfo, ) } // byName lists accounts filtered by wallet ID and account name. -func (p pgAccountListQueries) byName(ctx context.Context, +func (p accountListQueries) byName(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -310,27 +310,27 @@ func (p pgAccountListQueries) byName(ctx context.Context, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, - }, pgAccountRowToInfo, + }, accountRowToInfo, ) } // all lists all accounts for a wallet. -func (p pgAccountListQueries) all(ctx context.Context, +func (p accountListQueries) all(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), - pgAccountRowToInfo, + accountRowToInfo, ) } -// pgAccountGetQueries groups PostgreSQL account retrieval query methods. -type pgAccountGetQueries struct { +// accountGetQueries groups PostgreSQL account retrieval query methods. +type accountGetQueries struct { q *sqlcpg.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. -func (p pgAccountGetQueries) byNumber(ctx context.Context, +func (p accountGetQueries) byNumber(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -340,12 +340,12 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), - }, query, pgAccountRowToInfo, + }, query, accountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. -func (p pgAccountGetQueries) byName(ctx context.Context, +func (p accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -355,18 +355,18 @@ func (p pgAccountGetQueries) byName(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountName: *query.Name, - }, query, pgAccountRowToInfo, + }, query, accountRowToInfo, ) } -// pgAccountRenameQueries groups PostgreSQL account rename query methods. -type pgAccountRenameQueries struct { +// accountRenameQueries groups PostgreSQL account rename query methods. +type accountRenameQueries struct { q *sqlcpg.Queries } // byNumber renames an account identified by wallet ID, scope, and account // number. -func (p pgAccountRenameQueries) byNumber(ctx context.Context, +func (p accountRenameQueries) byNumber(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( @@ -383,7 +383,7 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. -func (p pgAccountRenameQueries) byName(ctx context.Context, +func (p accountRenameQueries) byName(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 46388b9d09..85346c1f35 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -7,9 +7,9 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// pgAddressTypeRowToInfo converts a PostgreSQL address type row to an +// addressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { +func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) if err != nil { return db.AddressTypeInfo{}, err @@ -27,7 +27,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, ) } @@ -38,6 +38,6 @@ func (s *PostgresStore) GetAddressType(ctx context.Context, return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, - pgAddressTypeRowToInfo, + addressTypeRowToInfo, ) } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 3a35ad3c88..2e9b13b736 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -27,7 +27,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), - }, pgAddressRowToInfo, + }, addressRowToInfo, ) } @@ -38,7 +38,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, func (s *PostgresStore) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - items, err := pgListAddressesByAccount(ctx, s.queries, query) + items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -67,7 +67,7 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ctx, s.queries.GetAddressSecret, addressID, addressSecretRowToSecret, ) } @@ -82,14 +82,14 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, sqlcpg.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - GetAccount: pgGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, - GetAccountID: newDerivedAddressGetAccountIDPg, - GetExtIndex: newDerivedAddressGetExtIndexPg, - GetIntIndex: newDerivedAddressGetIntIndexPg, - CreateAddr: newDerivedAddressCreateAddrPg, - RowID: newDerivedAddressRowIDPg, - RowCreatedAt: newDerivedAddressRowCreatedAtPg, + GetAccountID: derivedAddressGetAccountID, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, } return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) @@ -106,22 +106,22 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - GetAccount: pgGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: newImportedAddressGetAccountIDPg, - CreateAddr: pgCreateImportedAddress, - CreateParams: createImportedAddressParamsPg, - InsertSecret: pgInsertAddressSecret, - SecretParams: insertAddressSecretParamsPg, - RowID: importedAddressRowIDPg, - RowCreatedAt: importedAddressRowCreatedAtPg, + GetAccountID: importedAddressGetAccountID, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, } return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } -// pgGetAccountFromKey returns a helper to look up accounts by key. -func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, +// getAccountFromKey returns a helper to look up accounts by key. +func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, @@ -139,29 +139,29 @@ func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, } } -// newDerivedAddressGetAccountIDPg extracts the account ID from a row. -func newDerivedAddressGetAccountIDPg( +// derivedAddressGetAccountID extracts the account ID from a row. +func derivedAddressGetAccountID( row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// newDerivedAddressGetExtIndexPg returns the external index query. -func newDerivedAddressGetExtIndexPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressGetExtIndex returns the external index query. +func derivedAddressGetExtIndex(qtx *sqlcpg.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } -// newDerivedAddressGetIntIndexPg returns the internal index query. -func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressGetIntIndex returns the internal index query. +func derivedAddressGetIntIndex(qtx *sqlcpg.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } -// newDerivedAddressCreateAddrPg returns the derived address insert helper. -func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressCreateAddr returns the derived address insert helper. +func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, error) { @@ -195,42 +195,42 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, } } -// newDerivedAddressRowIDPg returns the created address ID. -func newDerivedAddressRowIDPg(row sqlcpg.CreateDerivedAddressRow) int64 { +// derivedAddressRowID returns the created address ID. +func derivedAddressRowID(row sqlcpg.CreateDerivedAddressRow) int64 { return row.ID } -// newDerivedAddressRowCreatedAtPg returns the CreatedAt timestamp. -func newDerivedAddressRowCreatedAtPg( +// derivedAddressRowCreatedAt returns the CreatedAt timestamp. +func derivedAddressRowCreatedAt( row sqlcpg.CreateDerivedAddressRow) time.Time { return row.CreatedAt } -// newImportedAddressGetAccountIDPg extracts the account ID from a row. -func newImportedAddressGetAccountIDPg( +// importedAddressGetAccountID extracts the account ID from a row. +func importedAddressGetAccountID( row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// pgCreateImportedAddress returns the imported address insert helper. -func pgCreateImportedAddress(qtx *sqlcpg.Queries) func(context.Context, +// createImportedAddress returns the imported address insert helper. +func createImportedAddress(qtx *sqlcpg.Queries) func(context.Context, sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } -// pgInsertAddressSecret returns the secret insert helper. -func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, +// insertAddressSecret returns the secret insert helper. +func insertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, sqlcpg.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } -// createImportedAddressParamsPg maps imported params to sqlc params. -func createImportedAddressParamsPg(accountID int64, +// createImportedAddressParams maps imported params to sqlc params. +func createImportedAddressParams(accountID int64, params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { return sqlcpg.CreateImportedAddressParams{ @@ -241,8 +241,8 @@ func createImportedAddressParamsPg(accountID int64, } } -// insertAddressSecretParamsPg maps imported params to secret params. -func insertAddressSecretParamsPg(addressID int64, +// insertAddressSecretParams maps imported params to secret params. +func insertAddressSecretParams(addressID int64, params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { return sqlcpg.InsertAddressSecretParams{ @@ -252,21 +252,21 @@ func insertAddressSecretParamsPg(addressID int64, } } -// importedAddressRowIDPg returns the created address ID. -func importedAddressRowIDPg(row sqlcpg.CreateImportedAddressRow) int64 { +// importedAddressRowID returns the created address ID. +func importedAddressRowID(row sqlcpg.CreateImportedAddressRow) int64 { return row.ID } -// importedAddressRowCreatedAtPg returns the CreatedAt timestamp. -func importedAddressRowCreatedAtPg( +// importedAddressRowCreatedAt returns the CreatedAt timestamp. +func importedAddressRowCreatedAt( row sqlcpg.CreateImportedAddressRow) time.Time { return row.CreatedAt } -// pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an +// addressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. -func pgAddressSecretRowToSecret( +func addressSecretRowToSecret( row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ @@ -276,17 +276,17 @@ func pgAddressSecretRowToSecret( }) } -// pgAddressInfoRow is a type constraint that unifies all PostgreSQL address +// addressInfoRow is a type constraint that unifies all PostgreSQL address // row types that share the same field structure. This enables a single // generic conversion function to handle all address query result types. -type pgAddressInfoRow interface { +type addressInfoRow interface { sqlcpg.GetAddressByScriptPubKeyRow | sqlcpg.ListAddressesByAccountRow } -// pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo +// addressRowToInfo converts a PostgreSQL address row to an AddressInfo // struct. -func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { +func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) @@ -316,13 +316,13 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { return info, nil } -// pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, +// listAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. -func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( - ctx, pgBuildAddressPageParams(query), + ctx, buildAddressPageParams(query), ) if err != nil { return nil, fmt.Errorf("list addresses by account: %w", err) @@ -330,7 +330,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, items := make([]db.AddressInfo, len(rows)) for i, row := range rows { - item, err := pgAddressRowToInfo(row) + item, err := addressRowToInfo(row) if err != nil { return nil, fmt.Errorf("list addresses by account: map address row: %w", @@ -343,9 +343,9 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, return items, nil } -// pgBuildAddressPageParams translates a ListAddresses query to +// buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. -func pgBuildAddressPageParams( +func buildAddressPageParams( q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { params := sqlcpg.ListAddressesByAccountParams{ diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index fbb70e1fcc..f4583b5f03 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -60,8 +60,8 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { execErr: errDummy, queryErr: errDummy, }) - deleteOps := pgDeleteTxOps{qtx: qtx} - rollbackOps := pgRollbackToBlockOps{qtx: qtx} + deleteOps := deleteTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") @@ -91,13 +91,13 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{qtx: qtx}, + createOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } - invalidateOps := pgInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := pgRollbackToBlockOps{qtx: qtx} - updateOps := &pgUpdateTxOps{qtx: qtx} - releaseOps := pgReleaseOutputOps{qtx: qtx} + invalidateOps := invalidateUnminedTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} + updateOps := &updateTxOps{qtx: qtx} + releaseOps := releaseOutputOps{qtx: qtx} err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, @@ -109,7 +109,7 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { ) require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentPg(t.Context(), qtx, db.CreateTxParams{ + err = markInputsSpent(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), @@ -175,15 +175,15 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }) require.NoError(t, err) - _, err = collectPgConflictRootIDs( + _, err = collectConflictRootIDs( t.Context(), nil, req, ) require.ErrorContains(t, err, "convert input outpoint index 0") - _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) + _, err = creditExists(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) require.ErrorContains(t, err, "convert credit index") - err = markInputsSpentPg(t.Context(), nil, db.CreateTxParams{ + err = markInputsSpent(t.Context(), nil, db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -198,24 +198,24 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") - err = pgRollbackToBlockOps{}.RewindWalletSyncStateHeights( + err = rollbackToBlockOps{}.RewindWalletSyncStateHeights( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - err = pgRollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( + err = rollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - _, _, err = buildPgConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + _, _, err = buildConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ ID: 1, TxHash: []byte{1}, TxStatus: 0, }}, map[int64]struct{}{1: {}}) require.ErrorContains(t, err, "tx hash") - leaseOps := &pgLeaseOutputOps{} + leaseOps := &leaseOutputOps{} _, err = leaseOps.Acquire(t.Context(), db.LeaseOutputParams{ WalletID: 1, diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 8da714683c..08505a2aed 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -92,8 +92,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - loadOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + loadOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), @@ -108,8 +108,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { Height: 7, Timestamp: time.Unix(77, 0), } - confirmOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + confirmOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow( t, "SELECT ?, ?, ?", @@ -126,8 +126,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }, db.CreateTxExistingTarget{}) require.ErrorIs(t, err, db.ErrTxNotFound) - conflictOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + conflictOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, @@ -146,15 +146,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} - stateOps := &pgUpdateTxOps{ + stateOps := &updateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, status: int16(db.TxStatusPublished), } - labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 4cd4881d40..16d29c6476 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -11,9 +11,9 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// buildPgBlock constructs a Block from the given PostgreSQL block +// buildBlock constructs a Block from the given PostgreSQL block // fields. -func buildPgBlock(height sql.NullInt32, hash []byte, +func buildBlock(height sql.NullInt32, hash []byte, timestamp sql.NullInt64) (*db.Block, error) { height32, err := db.NullInt32ToUint32(height) @@ -24,8 +24,8 @@ func buildPgBlock(height sql.NullInt32, hash []byte, return db.BuildBlock(hash, height32, timestamp.Int64) } -// ensureBlockExistsPg ensures that a block exists in the database. -func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, +// ensureBlockExists ensures that a block exists in the database. +func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, block *db.Block) error { height, err := db.Uint32ToInt32(block.Height) @@ -47,9 +47,9 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// requireBlockMatchesPg loads the shared block row for the provided height and +// requireBlockMatches loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. -func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlcpg.Queries, block *db.Block) (int32, error) { height, err := db.Uint32ToInt32(block.Height) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 8e40a65766..60a516d534 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -30,25 +30,25 @@ func (s *PostgresStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.CreateTxWithOps(ctx, req, &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + return db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, }, }) }) } -// pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. -type pgCreateTxOps struct { - pgInvalidateUnminedTxOps +// createTxOps adapts postgres sqlc queries to the shared CreateTx flow. +type createTxOps struct { + invalidateUnminedTxOps blockHeight sql.NullInt32 } -var _ db.CreateTxOps = (*pgCreateTxOps)(nil) +var _ db.CreateTxOps = (*createTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *pgCreateTxOps) LoadExisting(ctx context.Context, +func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -80,11 +80,11 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, } // ConfirmExisting promotes one existing unmined row to its confirmed state. -func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, +func (o *createTxOps) ConfirmExisting(ctx context.Context, req db.CreateTxRequest, _ db.CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) + blockHeight, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -110,7 +110,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the postgres // block-height value that the later Insert query will store. -func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, +func (o *createTxOps) PrepareBlock(ctx context.Context, req db.CreateTxRequest) error { o.blockHeight = sql.NullInt32{} @@ -119,7 +119,7 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, return nil } - height, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) + height, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -131,10 +131,10 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, +func (o *createTxOps) ListConflictTxns(ctx context.Context, req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { - rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) + rootIDs, err := collectConflictRootIDs(ctx, o.qtx, req) if err != nil { return nil, nil, err } @@ -148,12 +148,12 @@ func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, return nil, nil, fmt.Errorf("list unmined txns: %w", err) } - return buildPgConflictRoots(rows, rootIDs) + return buildConflictRoots(rows, rootIDs) } -// collectPgConflictRootIDs returns the active unmined spender row IDs +// collectConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. -func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, +func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -194,9 +194,9 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, return rootIDs, nil } -// buildPgConflictRoots maps the selected unmined rows into ordered root IDs and +// buildConflictRoots maps the selected unmined rows into ordered root IDs and // the matching root hashes used for descendant discovery. -func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -221,7 +221,7 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, } // Insert stores one new postgres transaction row for CreateTx. -func (o *pgCreateTxOps) Insert(ctx context.Context, +func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ @@ -242,22 +242,22 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, } // InsertCredits stores any wallet-owned outputs created by the transaction. -func (o *pgCreateTxOps) InsertCredits(ctx context.Context, +func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCreditsPg(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. -func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, +func (o *createTxOps) MarkInputsSpent(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return markInputsSpentPg(ctx, o.qtx, req.Params, txID) + return markInputsSpent(ctx, o.qtx, req.Params, txID) } // MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *pgCreateTxOps) MarkTxnsReplaced( +func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -276,7 +276,7 @@ func (o *pgCreateTxOps) MarkTxnsReplaced( // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *pgCreateTxOps) InsertReplacementEdges( +func (o *createTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -297,13 +297,13 @@ func (o *pgCreateTxOps) InsertReplacementEdges( return nil } -// insertCreditsPg inserts one wallet-owned UTXO row for each credited output of +// insertCredits inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. -func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, +func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExistsPg( + creditExists, err := creditExists( ctx, qtx, params.WalletID, params.Tx.TxHash(), index, ) if err != nil { @@ -351,9 +351,9 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// creditExistsPg reports whether the wallet already has a UTXO row for the +// creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, +func creditExists(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { convertedIndex, err := db.Uint32ToInt32(outputIndex) @@ -381,7 +381,7 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return true, nil } -// markInputsSpentPg attaches wallet-owned outpoints spent by the stored +// markInputsSpent attaches wallet-owned outpoints spent by the stored // transaction to its row ID and input indexes. // // If another wallet transaction already owns the spend edge for a @@ -389,7 +389,7 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -420,7 +420,7 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, } if rowsAffected == 0 { - err = ensureSpendConflictPg( + err = ensureSpendConflict( ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, outputIndex, txID, ) @@ -433,11 +433,11 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// ensureSpendConflictPg reports ErrTxInputConflict when the referenced outpoint +// ensureSpendConflict reports ErrTxInputConflict when the referenced outpoint // is wallet-owned, still eligible for spending, and already attached to another // transaction. If the wallet owns the parent output but that parent is already // invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32, txID int64) error { @@ -450,7 +450,7 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return ensureWalletParentValidPg( + return ensureWalletParentValid( ctx, qtx, walletID, txHash, outputIndex, ) } @@ -465,9 +465,9 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// ensureWalletParentValidPg reports ErrTxInputInvalidParent when the wallet +// ensureWalletParentValid reports ErrTxInputInvalidParent when the wallet // owns the referenced outpoint but its parent transaction is already invalid. -func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureWalletParentValid(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 578e560a3a..a5b8efb968 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -22,23 +22,23 @@ func (s *PostgresStore) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } -// pgDeleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. -type pgDeleteTxOps struct { +// deleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. +type deleteTxOps struct { qtx *sqlcpg.Queries } -var _ db.DeleteTxOps = (*pgDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*deleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, +func (o deleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { - meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) + meta, err := getDeleteTxMeta(ctx, o.qtx, walletID, txHash) if err != nil { return 0, err } @@ -48,15 +48,15 @@ func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, // EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o pgDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, +func (o deleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { - return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) + return ensureDeleteLeaf(ctx, o.qtx, walletID, txHash, txID) } // ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, +func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -75,7 +75,7 @@ func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, +func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -94,7 +94,7 @@ func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, // DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, +func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -111,10 +111,10 @@ func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, return rows, nil } -// ensureDeleteLeafPg rejects DeleteTx requests for transactions that still have +// ensureDeleteLeaf rejects DeleteTx requests for transactions that still have // direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -148,9 +148,9 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// getDeleteTxMetaPg loads the transaction metadata DeleteTx needs and enforces +// getDeleteTxMeta loads the transaction metadata DeleteTx needs and enforces // the unmined precondition up front. -func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash) ( sqlcpg.GetTransactionMetaByHashRow, error) { diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index abbebf3f22..bb3c10dee4 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -32,15 +32,15 @@ func (s *PostgresStore) GetTx(ctx context.Context, return nil, fmt.Errorf("get tx: %w", err) } - return txInfoFromPgRow( + return txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, ) } -// txInfoFromPgRow converts one normalized postgres query row into the public +// txInfoFromRow converts one normalized postgres query row into the public // TxInfo shape. -func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, +func txInfoFromRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, status int64, label string) (*db.TxInfo, error) { @@ -52,7 +52,7 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, // Unmined rows legitimately have no block metadata, so only build the Block // shape when the row still carries a valid height. if blockHeight.Valid { - block, err = buildPgBlock(blockHeight, blockHash, blockTimestamp) + block, err = buildBlock(blockHeight, blockHash, blockTimestamp) if err != nil { return nil, err } diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index 1a0d349dfe..2e4fe1a964 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -18,22 +18,22 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.InvalidateUnminedTxWithOps( - ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, + ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) }) } -// pgInvalidateUnminedTxOps adapts postgres sqlc queries to the shared +// invalidateUnminedTxOps adapts postgres sqlc queries to the shared // InvalidateUnminedTx workflow. -type pgInvalidateUnminedTxOps struct { +type invalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ db.InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, +func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( @@ -68,7 +68,7 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( +func (o invalidateUnminedTxOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -85,7 +85,7 @@ func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( // ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, +func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -106,7 +106,7 @@ func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, // MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( +func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 574f27a485..8a37df6e42 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -19,17 +19,17 @@ func (s *PostgresStore) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { - return s.listTxnsWithoutBlockPg(ctx, query.WalletID) + return s.listTxnsWithoutBlock(ctx, query.WalletID) } - return s.listConfirmedTxnsPg(ctx, query) + return s.listConfirmedTxns(ctx, query) } -// listTxnsWithoutBlockPg loads every transaction row that currently has no +// listTxnsWithoutBlock loads every transaction row that currently has no // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, +func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -39,7 +39,7 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - info, err := txInfoFromPgRow( + info, err := txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, ) @@ -53,9 +53,9 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, return infos, nil } -// listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns +// listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. -func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, +func (s *PostgresStore) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) @@ -81,7 +81,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - block, err := buildPgBlock( + block, err := buildBlock( row.BlockHeight, row.BlockHash, sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index 836ce9a564..e0a8e14a15 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -18,21 +18,21 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.RollbackToBlockWithOps(ctx, height, - pgRollbackToBlockOps{qtx: qtx}) + rollbackToBlockOps{qtx: qtx}) }) } -// pgRollbackToBlockOps adapts postgres sqlc queries to the shared rollback +// rollbackToBlockOps adapts postgres sqlc queries to the shared rollback // sequence. -type pgRollbackToBlockOps struct { +type rollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ db.RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, +func (o rollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rollbackHeight, err := db.Uint32ToInt32(height) @@ -45,12 +45,12 @@ func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, return nil, fmt.Errorf("query rollback coinbase roots: %w", err) } - return groupRollbackCoinbaseRootsPg(rows) + return groupRollbackCoinbaseRoots(rows) } // RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( +func (o rollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { // PostgreSQL stores block heights as INTEGER today, so rollback still needs @@ -89,7 +89,7 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( // DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( +func (o rollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { rollbackHeight, err := db.Uint32ToInt32(height) @@ -107,7 +107,7 @@ func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( // MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, +func (o rollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { for _, txHash := range rootHashes { @@ -137,7 +137,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o pgRollbackToBlockOps) ListUnminedTxRecords( +func (o rollbackToBlockOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -154,7 +154,7 @@ func (o pgRollbackToBlockOps) ListUnminedTxRecords( // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o pgRollbackToBlockOps) ClearDescendantSpends( +func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -175,7 +175,7 @@ func (o pgRollbackToBlockOps) ClearDescendantSpends( // MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o pgRollbackToBlockOps) MarkDescendantsFailed( +func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -192,9 +192,9 @@ func (o pgRollbackToBlockOps) MarkDescendantsFailed( return nil } -// groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by +// groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( +func groupRollbackCoinbaseRoots(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index f584b44cb0..29b12d7b3b 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -21,12 +21,12 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } -// pgUpdateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. -type pgUpdateTxOps struct { +// updateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. +type updateTxOps struct { // qtx is the transaction-scoped postgres query set used by UpdateTx. qtx *sqlcpg.Queries @@ -39,11 +39,11 @@ type pgUpdateTxOps struct { status int16 } -var _ db.UpdateTxOps = (*pgUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*updateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, +func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -66,13 +66,13 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, // PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. -func (o *pgUpdateTxOps) PrepareState(ctx context.Context, +func (o *updateTxOps) PrepareState(ctx context.Context, state db.UpdateTxState) error { blockHeight := sql.NullInt32{} if state.Block != nil { - height, err := requireBlockMatchesPg(ctx, o.qtx, state.Block) + height, err := requireBlockMatches(ctx, o.qtx, state.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -88,7 +88,7 @@ func (o *pgUpdateTxOps) PrepareState(ctx context.Context, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( @@ -112,7 +112,7 @@ func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } // UpdateLabel writes one user-visible label change. -func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( diff --git a/wallet/internal/db/pg/utxo_extra_test.go b/wallet/internal/db/pg/utxo_extra_test.go index 959cca6f7a..2edbeeefa9 100644 --- a/wallet/internal/db/pg/utxo_extra_test.go +++ b/wallet/internal/db/pg/utxo_extra_test.go @@ -14,7 +14,7 @@ func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { t.Parallel() hash := chainhash.Hash{15} - _, err := utxoInfoFromPgRow( + _, err := utxoInfoFromRow( hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, sql.NullInt32{}, ) @@ -26,7 +26,7 @@ func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { t.Parallel() hash := chainhash.Hash{16} - _, err := utxoInfoFromPgRow( + _, err := utxoInfoFromRow( hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, sql.NullInt32{Int32: -1, Valid: true}, ) diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 3f07f0bea3..c12bad5a0c 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -39,15 +39,15 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, return nil, fmt.Errorf("get utxo: %w", err) } - return utxoInfoFromPgRow( + return utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) } -// utxoInfoFromPgRow converts one normalized postgres query row into the public +// utxoInfoFromRow converts one normalized postgres query row into the public // UtxoInfo shape. -func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, +func utxoInfoFromRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt32) (*db.UtxoInfo, error) { diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 82c57727d4..d40a7cfdc7 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -23,7 +23,7 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( - ctx, params, &pgLeaseOutputOps{qtx: qtx}, + ctx, params, &leaseOutputOps{qtx: qtx}, ) if err != nil { return err @@ -40,17 +40,17 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, return lease, nil } -// pgLeaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput +// leaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput // workflow. -type pgLeaseOutputOps struct { +type leaseOutputOps struct { qtx *sqlcpg.Queries } -var _ db.LeaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) // Acquire attempts to write or renew one postgres lease row for the requested // outpoint. -func (o *pgLeaseOutputOps) Acquire(ctx context.Context, +func (o *leaseOutputOps) Acquire(ctx context.Context, params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -82,7 +82,7 @@ func (o *pgLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, +func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 888a9186c9..2f1f65e828 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -15,14 +15,14 @@ import ( func (s *PostgresStore) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) + rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { - utxo, err := utxoInfoFromPgRow( + utxo, err := utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) @@ -36,9 +36,9 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, return utxos, nil } -// buildListUtxosParamsPg prepares the typed nullable filters required by the +// buildListUtxosParams prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParamsPg(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { +func buildListUtxosParams(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 694fd4668c..ed87995902 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -21,22 +21,22 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.ReleaseOutputWithOps( - ctx, params, &pgReleaseOutputOps{qtx: qtx}, + ctx, params, &releaseOutputOps{qtx: qtx}, ) }) } -// pgReleaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput +// releaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput // workflow. -type pgReleaseOutputOps struct { +type releaseOutputOps struct { qtx *sqlcpg.Queries } -var _ db.ReleaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. -func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, +func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) @@ -64,7 +64,7 @@ func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, // Release attempts to delete the postgres lease row for the provided UTXO ID // and lock ID. -func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -83,7 +83,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, // ActiveLockID returns the currently active postgres lease lock ID for the // provided UTXO ID. -func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index a83070270d..da6c148ad1 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -84,7 +84,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, ) } - info, err = buildPgWalletInfo(pgWalletRowParams{ + info, err = buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -127,7 +127,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, return nil, fmt.Errorf("get wallet: %w", err) } - return buildPgWalletInfo(pgWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -147,7 +147,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, func (s *PostgresStore) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) + rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) if err != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) @@ -155,7 +155,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, items := make([]db.WalletInfo, len(rows)) for i, row := range rows { - item, errMap := pgListWalletRowToInfo(row) + item, errMap := listWalletRowToInfo(row) if errMap != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) @@ -193,7 +193,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { - err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) + err := ensureBlockExists(ctx, qtx, params.SyncedTo) if err != nil { return fmt.Errorf("ensure synced block: %w", err) @@ -201,7 +201,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, } if params.BirthdayBlock != nil { - err := ensureBlockExistsPg( + err := ensureBlockExists( ctx, qtx, params.BirthdayBlock, ) if err != nil { @@ -210,7 +210,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, } } - syncParams, err := buildUpdateSyncParamsPg(params) + syncParams, err := buildUpdateSyncParams(params) if err != nil { return err } @@ -280,9 +280,9 @@ func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, return nil } -// pgWalletRowParams holds the parameters needed to build a WalletInfo +// walletRowParams holds the parameters needed to build a WalletInfo // from a wallet row. -type pgWalletRowParams struct { +type walletRowParams struct { id int64 name string isImported bool @@ -297,10 +297,10 @@ type pgWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } -// pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { - return buildPgWalletInfo(pgWalletRowParams{ +func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -316,9 +316,9 @@ func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { }) } -// pgListWalletsParams translates a page request to ListWallets query +// listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. -func pgListWalletsParams( +func listWalletsParams( req page.Request[uint32]) sqlcpg.ListWalletsParams { params := sqlcpg.ListWalletsParams{ @@ -335,9 +335,9 @@ func pgListWalletsParams( return params } -// buildPgWalletInfo constructs a WalletInfo from the given wallet row +// buildWalletInfo constructs a WalletInfo from the given wallet row // parameters. -func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { +func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err @@ -356,7 +356,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { } if row.syncedHeight.Valid { - block, err := buildPgBlock( + block, err := buildBlock( row.syncedHeight, row.syncedBlockHash, row.syncedBlockTimestamp, @@ -369,7 +369,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { } if row.birthdayHeight.Valid { - block, err := buildPgBlock( + block, err := buildBlock( row.birthdayHeight, row.birthdayBlockHash, row.birthdayBlockTimestamp, @@ -384,9 +384,9 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { return info, nil } -// buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from +// buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsPg(params db.UpdateWalletParams) ( +func buildUpdateSyncParams(params db.UpdateWalletParams) ( sqlcpg.UpdateWalletSyncStateParams, error) { syncParams := sqlcpg.UpdateWalletSyncStateParams{ From 5ff50eb6c5273fc732bd5a2637141df119a2087c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:01:03 +0800 Subject: [PATCH 228/695] db/sqlite: rename exported store API Rename the sqlite backend's exported store API to the package-local Store, Config, and NewStore names now that the backend lives in its own package. The sqlite config type and tests also move into the sqlite package so callers import the backend package for its concrete constructor surface. This removes package stutter at sqlite call sites while keeping the shared db package focused on backend-neutral helpers and errors. --- wallet/internal/db/config.go | 23 ----- wallet/internal/db/config_test.go | 71 -------------- wallet/internal/db/db_connectors_test.go | 16 +-- .../internal/db/itest/fixtures_sqlite_test.go | 34 +++---- wallet/internal/db/itest/sqlite_test.go | 30 +++--- .../db/itest/tx_corruption_sqlite_test.go | 11 +-- wallet/internal/db/sqlite/accounts.go | 84 ++++++++-------- wallet/internal/db/sqlite/address_types.go | 10 +- wallet/internal/db/sqlite/addresses.go | 98 +++++++++---------- .../internal/db/sqlite/backend_error_test.go | 6 +- .../internal/db/sqlite/backend_rows_test.go | 18 ++-- wallet/internal/db/sqlite/block.go | 10 +- wallet/internal/db/sqlite/config.go | 26 +++++ wallet/internal/db/sqlite/config_test.go | 79 +++++++++++++++ wallet/internal/db/sqlite/doc.go | 2 + wallet/internal/db/sqlite/itest.go | 6 +- wallet/internal/db/sqlite/store.go | 23 +++-- wallet/internal/db/sqlite/txstore_createtx.go | 46 ++++----- wallet/internal/db/sqlite/txstore_deletetx.go | 34 +++---- wallet/internal/db/sqlite/txstore_gettx.go | 8 +- .../db/sqlite/txstore_invalidateunmined.go | 18 ++-- wallet/internal/db/sqlite/txstore_listtxns.go | 12 +-- wallet/internal/db/sqlite/txstore_rollback.go | 22 ++--- wallet/internal/db/sqlite/txstore_updatetx.go | 16 +-- .../internal/db/sqlite/utxostore_balance.go | 8 +- .../internal/db/sqlite/utxostore_getutxo.go | 8 +- .../db/sqlite/utxostore_leaseoutput.go | 14 +-- .../db/sqlite/utxostore_listleasedoutputs.go | 8 +- .../internal/db/sqlite/utxostore_listutxos.go | 8 +- .../db/sqlite/utxostore_releaseoutput.go | 16 +-- wallet/internal/db/sqlite/wallet.go | 42 ++++---- wallet/internal/db/tx.go | 2 +- 32 files changed, 410 insertions(+), 399 deletions(-) create mode 100644 wallet/internal/db/sqlite/config.go create mode 100644 wallet/internal/db/sqlite/config_test.go create mode 100644 wallet/internal/db/sqlite/doc.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go index 0df15b22b9..43363a14ab 100644 --- a/wallet/internal/db/config.go +++ b/wallet/internal/db/config.go @@ -36,29 +36,6 @@ var ( ErrEmptyDSN = errors.New("DSN is required") ) -// SqliteConfig holds the configuration for the SQLite database. -type SqliteConfig struct { - // DBPath is the filesystem path to the SQLite database file. - DBPath string - - // MaxConnections is the maximum number of open connections to the - // database. Set to zero to use DefaultMaxConnections. - MaxConnections int -} - -// Validate checks that the SqliteConfig values are valid. -func (c *SqliteConfig) Validate() error { - if c.DBPath == "" { - return ErrEmptyDBPath - } - - if c.MaxConnections < 0 { - return ErrNegativeMaxConns - } - - return nil -} - // PostgresConfig holds the configuration for the PostgreSQL database. type PostgresConfig struct { // Dsn is the database connection string. diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/config_test.go index 142f8925a8..5981fba9a1 100644 --- a/wallet/internal/db/config_test.go +++ b/wallet/internal/db/config_test.go @@ -6,77 +6,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestSqliteConfigValidateSuccess tests valid SqliteConfig scenarios. -func TestSqliteConfigValidateSuccess(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - config SqliteConfig - }{ - { - name: "valid config with zero max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: 0, - }, - }, - { - name: "valid config with positive max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: 10, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := tc.config.Validate() - require.NoError(t, err) - }) - } -} - -// TestSqliteConfigValidateErrors tests SqliteConfig validation errors. -func TestSqliteConfigValidateErrors(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - config SqliteConfig - expectedErr error - }{ - { - name: "empty DB path", - config: SqliteConfig{ - DBPath: "", - MaxConnections: 0, - }, - expectedErr: ErrEmptyDBPath, - }, - { - name: "negative max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: -1, - }, - expectedErr: ErrNegativeMaxConns, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := tc.config.Validate() - require.ErrorIs(t, err, tc.expectedErr) - }) - } -} - // TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. func TestPostgresConfigValidateSuccess(t *testing.T) { t.Parallel() diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index f0c24649d2..49f1dcdf1a 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -65,24 +65,24 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { require.Nil(t, store) } -func TestNewSqliteStoreValidateConfig(t *testing.T) { +func TestSQLiteNewStoreValidateConfig(t *testing.T) { t.Parallel() tests := []struct { name string - cfg db.SqliteConfig + cfg dbsqlite.Config wantErr error }{ { name: "empty DB path", - cfg: db.SqliteConfig{ + cfg: dbsqlite.Config{ DBPath: "", }, wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: db.SqliteConfig{ + cfg: dbsqlite.Config{ DBPath: "/tmp/test.db", MaxConnections: -1, }, @@ -94,21 +94,21 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbsqlite.NewSqliteStore(t.Context(), tc.cfg) + store, err := dbsqlite.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) } } -func TestNewSqliteStoreSuccess(t *testing.T) { +func TestSQLiteNewStoreSuccess(t *testing.T) { t.Parallel() - cfg := db.SqliteConfig{ + cfg := dbsqlite.Config{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index cb0a8d5d6c..634dcb0a4b 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,19 +10,19 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, +func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { t.Helper() block := NewBlockFixture(height) err := queries.InsertBlock( - t.Context(), sqlcsqlite.InsertBlockParams{ + t.Context(), sqlc.InsertBlockParams{ BlockHeight: int64(block.Height), HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -35,12 +35,12 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, // CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, +func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, scopeID int64, accountNumber uint32, name string) { t.Helper() _, err := queries.CreateDerivedAccountWithNumber( - t.Context(), sqlcsqlite.CreateDerivedAccountWithNumberParams{ + t.Context(), sqlc.CreateDerivedAccountWithNumberParams{ ScopeID: scopeID, AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, AccountName: name, @@ -54,12 +54,12 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. -func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, +func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, accountID int64, branch uint32, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( - t.Context(), sqlcsqlite.CreateDerivedAddressParams{ + t.Context(), sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int64(db.WitnessPubKey), @@ -99,12 +99,12 @@ func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, } // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. -func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, +func GetKeyScopeID(t *testing.T, queries *sqlc.Queries, walletID uint32, scope db.KeyScope) int64 { t.Helper() row, err := queries.GetKeyScopeByWalletAndScope( - t.Context(), sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + t.Context(), sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -116,13 +116,13 @@ func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, } // GetAccountID retrieves the account ID for a given scope and account name. -func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, +func GetAccountID(t *testing.T, queries *sqlc.Queries, scopeID int64, accountName string) int64 { t.Helper() row, err := queries.GetAccountByScopeAndName( t.Context(), - sqlcsqlite.GetAccountByScopeAndNameParams{ + sqlc.GetAccountByScopeAndNameParams{ ScopeID: scopeID, AccountName: accountName, }, @@ -132,12 +132,12 @@ func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, return row.ID } -func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, +func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, walletID uint32) int64 { t.Helper() addr, err := queries.GetAddressByScriptPubKey( - t.Context(), sqlcsqlite.GetAddressByScriptPubKeyParams{ + t.Context(), sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: scriptPubKey, WalletID: int64(walletID), }, @@ -147,8 +147,8 @@ func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, - addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { +func GetAddressSecret(t *testing.T, queries *sqlc.Queries, + addressID int64) (sqlc.GetAddressSecretRow, error) { t.Helper() return queries.GetAddressSecret(t.Context(), addressID) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbsqlite.SqliteStore{}, store) + require.IsType(t, &dbsqlite.Store{}, store) - sqliteStore := store.(*dbsqlite.SqliteStore) + sqliteStore := store.(*dbsqlite.Store) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index fad55c422b..8d54ab96c2 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -12,24 +12,24 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { +func NewTestStore(t *testing.T) *dbsqlite.Store { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - cfg := db.SqliteConfig{ + cfg := dbsqlite.Config{ DBPath: dbPath, MaxConnections: 0, } - store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -41,14 +41,14 @@ func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, +func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -56,7 +56,7 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, require.NoError(t, err) childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( - t.Context(), sqlcsqlite.ListSpendingTxIDsByParentTxIDParams{ + t.Context(), sqlc.ListSpendingTxIDsByParentTxIDParams{ WalletID: int64(walletID), TxID: meta.ID, }, @@ -74,13 +74,13 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -98,13 +98,13 @@ func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() row, err := store.Queries().GetTransactionByHash( - t.Context(), sqlcsqlite.GetTransactionByHashParams{ + t.Context(), sqlc.GetTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -116,7 +116,7 @@ func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -125,7 +125,7 @@ func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, require.True(t, ok) rows, err := store.Queries().UpdateTransactionStatusByIDs( - t.Context(), sqlcsqlite.UpdateTransactionStatusByIDsParams{ + t.Context(), sqlc.UpdateTransactionStatusByIDsParams{ WalletID: int64(walletID), Status: int64(status), TxIds: []int64{txID}, @@ -137,14 +137,14 @@ func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbsqlite.SqliteStore, +func walletUtxoExists(t *testing.T, store *dbsqlite.Store, walletID uint32, outPoint wire.OutPoint) bool { t.Helper() _, err := store.Queries().GetUtxoIDByOutpoint( - t.Context(), sqlcsqlite.GetUtxoIDByOutpointParams{ + t.Context(), sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(walletID), TxHash: outPoint.Hash[:], OutputIndex: int64(outPoint.Index), diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index bd5209823b..6a05e1497c 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - "github.com/btcsuite/btcwallet/wallet/internal/db" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -20,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -55,7 +54,7 @@ func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -90,7 +89,7 @@ func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -135,7 +134,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -171,7 +170,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.SqliteStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index fb9b9ae041..e6315a4f21 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -4,17 +4,17 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// Ensure SqliteStore satisfies the AccountStore interface. -var _ db.AccountStore = (*SqliteStore)(nil) +// Ensure Store satisfies the AccountStore interface. +var _ db.AccountStore = (*Store)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (s *SqliteStore) GetAccount(ctx context.Context, +func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := accountGetQueries{q: s.queries} @@ -26,7 +26,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (s *SqliteStore) ListAccounts(ctx context.Context, +func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := accountListQueries{q: s.queries} @@ -38,7 +38,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (s *SqliteStore) RenameAccount(ctx context.Context, +func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { renameQueries := accountRenameQueries{q: s.queries} @@ -51,7 +51,7 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, +func (s *Store) CreateDerivedAccount(ctx context.Context, params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() @@ -61,7 +61,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -70,7 +70,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, } row, err := qtx.CreateDerivedAccount( - ctx, sqlcsqlite.CreateDerivedAccountParams{ + ctx, sqlc.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int64(db.DerivedAccount), @@ -108,18 +108,18 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, // ensureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. -func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureKeyScope(ctx context.Context, qtx *sqlc.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, - sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema db.ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { - return sqlcsqlite.CreateKeyScopeParams{ + func(addrSchema db.ScopeAddrSchema) sqlc.CreateKeyScopeParams { + return sqlc.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -132,7 +132,7 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, ), } }, - func(row sqlcsqlite.KeyScope) int64 { return row.ID }, scope, + func(row sqlc.KeyScope) int64 { return row.ID }, scope, ) } @@ -140,12 +140,12 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (s *SqliteStore) CreateImportedAccount(ctx context.Context, +func (s *Store) CreateImportedAccount(ctx context.Context, params db.CreateImportedAccountParams) (*db.AccountProperties, error) { var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( @@ -157,7 +157,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, }, qtx.CreateImportedAccount, buildCreateImportedAccountArgs(params), - func(row sqlcsqlite.CreateImportedAccountRow) int64 { + func(row sqlc.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), @@ -179,12 +179,12 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, // CreateImportedAccountParams for SQLite. func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, -) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { +) func(int64, bool) sqlc.CreateImportedAccountParams { return func(scopeID int64, - isWatchOnly bool) sqlcsqlite.CreateImportedAccountParams { + isWatchOnly bool) sqlc.CreateImportedAccountParams { - return sqlcsqlite.CreateImportedAccountParams{ + return sqlc.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int64(db.ImportedAccount), @@ -202,10 +202,10 @@ func buildCreateImportedAccountArgs( // CreateAccountSecretParams for SQLite. func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, -) func(int64) sqlcsqlite.CreateAccountSecretParams { +) func(int64) sqlc.CreateAccountSecretParams { - return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { - return sqlcsqlite.CreateAccountSecretParams{ + return func(accountID int64) sqlc.CreateAccountSecretParams { + return sqlc.CreateAccountSecretParams{ AccountID: accountID, EncryptedPrivateKey: params.EncryptedPrivateKey, } @@ -214,7 +214,7 @@ func buildCreateAccountSecretArgs( // getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, +func getAccountProps(ctx context.Context, qtx *sqlc.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -246,13 +246,13 @@ func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. type accountInfoRow interface { - sqlcsqlite.GetAccountByScopeAndNameRow | - sqlcsqlite.GetAccountByScopeAndNumberRow | - sqlcsqlite.GetAccountByWalletScopeAndNameRow | - sqlcsqlite.GetAccountByWalletScopeAndNumberRow | - sqlcsqlite.ListAccountsByWalletRow | - sqlcsqlite.ListAccountsByWalletScopeRow | - sqlcsqlite.ListAccountsByWalletAndNameRow + sqlc.GetAccountByScopeAndNameRow | + sqlc.GetAccountByScopeAndNumberRow | + sqlc.GetAccountByWalletScopeAndNameRow | + sqlc.GetAccountByWalletScopeAndNumberRow | + sqlc.ListAccountsByWalletRow | + sqlc.ListAccountsByWalletScopeRow | + sqlc.ListAccountsByWalletAndNameRow } // accountRowToInfo converts a SQLite account row to an AccountInfo @@ -263,7 +263,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcsqlite.GetAccountByScopeAndNameRow(row) + base := sqlc.GetAccountByScopeAndNameRow(row) return db.AccountRowToInfo(db.AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, @@ -282,7 +282,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, // accountListQueries groups SQLite account listing query methods. type accountListQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byScope lists accounts filtered by wallet ID and key scope. @@ -291,7 +291,7 @@ func (s accountListQueries) byScope(ctx context.Context, return db.ListAccounts( ctx, s.q.ListAccountsByWalletScope, - sqlcsqlite.ListAccountsByWalletScopeParams{ + sqlc.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -305,7 +305,7 @@ func (s accountListQueries) byName(ctx context.Context, return db.ListAccounts( ctx, s.q.ListAccountsByWalletAndName, - sqlcsqlite.ListAccountsByWalletAndNameParams{ + sqlc.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, }, accountRowToInfo, @@ -324,7 +324,7 @@ func (s accountListQueries) all(ctx context.Context, // accountGetQueries groups SQLite account retrieval query methods. type accountGetQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. @@ -333,7 +333,7 @@ func (s accountGetQueries) byNumber(ctx context.Context, return db.GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, - sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ + sqlc.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -347,7 +347,7 @@ func (s accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, - sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -358,7 +358,7 @@ func (s accountGetQueries) byName(ctx context.Context, // accountRenameQueries groups SQLite account rename query methods. type accountRenameQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byNumber renames an account identified by wallet ID, scope, and account @@ -368,7 +368,7 @@ func (s accountRenameQueries) byNumber(ctx context.Context, return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, - sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ + sqlc.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), @@ -385,7 +385,7 @@ func (s accountRenameQueries) byName(ctx context.Context, return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, - sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ + sqlc.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index ae2e39ed4e..1f39b389a7 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -2,14 +2,14 @@ package sqlite import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // addressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, +func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) @@ -25,7 +25,7 @@ func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( +func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( @@ -35,7 +35,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (s *SqliteStore) GetAddressType(ctx context.Context, +func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { return db.GetAddressTypeByID( diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index a5c7a5a9fb..1dea5eeaec 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -9,14 +9,14 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -var _ db.AddressStore = (*SqliteStore)(nil) +var _ db.AddressStore = (*Store)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (s *SqliteStore) GetAddress(ctx context.Context, +func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { getByScript := func(ctx context.Context, @@ -24,7 +24,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, - sqlcsqlite.GetAddressByScriptPubKeyParams{ + sqlc.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, }, addressRowToInfo, @@ -35,7 +35,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, } // ListAddresses returns a page of addresses matching the given query. -func (s *SqliteStore) ListAddresses(ctx context.Context, +func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := listAddressesByAccount(ctx, s.queries, query) @@ -54,7 +54,7 @@ func (s *SqliteStore) ListAddresses(ctx context.Context, } // IterAddresses returns an iterator over paginated address results. -func (s *SqliteStore) IterAddresses(ctx context.Context, +func (s *Store) IterAddresses(ctx context.Context, query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( @@ -63,7 +63,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (s *SqliteStore) GetAddressSecret(ctx context.Context, +func (s *Store) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( @@ -74,15 +74,15 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. -func (s *SqliteStore) NewDerivedAddress(ctx context.Context, +func (s *Store) NewDerivedAddress(ctx context.Context, params db.NewDerivedAddressParams, deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ - *sqlcsqlite.Queries, - sqlcsqlite.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcsqlite.CreateDerivedAddressRow]{ + sqlc.CreateDerivedAddressRow]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, GetAccountID: derivedAddressGetAccountID, @@ -99,16 +99,16 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, } // NewImportedAddress imports a new address, script, or private key. -func (s *SqliteStore) NewImportedAddress(ctx context.Context, +func (s *Store) NewImportedAddress(ctx context.Context, params db.NewImportedAddressParams) (*db.AddressInfo, error) { adapters := db.ImportedAddressAdapters[ - *sqlcsqlite.Queries, - sqlcsqlite.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcsqlite.CreateImportedAddressParams, - sqlcsqlite.CreateImportedAddressRow, - sqlcsqlite.InsertAddressSecretParams]{ + sqlc.CreateImportedAddressParams, + sqlc.CreateImportedAddressRow, + sqlc.InsertAddressSecretParams]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, GetAccountID: importedAddressGetAccountID, @@ -124,15 +124,15 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, } // getAccountFromKey returns a helper to look up accounts by key. -func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { +func getAccountFromKey(qtx *sqlc.Queries) func(context.Context, + db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( - ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + ctx, sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: key.WalletID, Purpose: key.Purpose, CoinType: key.CoinType, @@ -144,38 +144,38 @@ func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, // derivedAddressGetAccountID extracts the account ID from a row. func derivedAddressGetAccountID( - row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // derivedAddressGetExtIndex returns the external index query. func derivedAddressGetExtIndex( - qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } // derivedAddressGetIntIndex returns the internal index query. func derivedAddressGetIntIndex( - qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr( - qtx *sqlcsqlite.Queries, + qtx *sqlc.Queries, ) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( - sqlcsqlite.CreateDerivedAddressRow, error, + sqlc.CreateDerivedAddressRow, error, ) { return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { return qtx.CreateDerivedAddress( - ctx, sqlcsqlite.CreateDerivedAddressParams{ + ctx, sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int64(addrType), @@ -195,45 +195,45 @@ func derivedAddressCreateAddr( // derivedAddressRowID returns the created address ID. func derivedAddressRowID( - row sqlcsqlite.CreateDerivedAddressRow) int64 { + row sqlc.CreateDerivedAddressRow) int64 { return row.ID } // derivedAddressRowCreatedAt returns the CreatedAt timestamp. func derivedAddressRowCreatedAt( - row sqlcsqlite.CreateDerivedAddressRow) time.Time { + row sqlc.CreateDerivedAddressRow) time.Time { return row.CreatedAt } // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( - row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // createImportedAddress returns the imported address insert helper. -func createImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, - sqlcsqlite.CreateImportedAddressParams) ( - sqlcsqlite.CreateImportedAddressRow, error) { +func createImportedAddress(qtx *sqlc.Queries) func(context.Context, + sqlc.CreateImportedAddressParams) ( + sqlc.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } // insertAddressSecret returns the secret insert helper. -func insertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, - sqlcsqlite.InsertAddressSecretParams) error { +func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, + sqlc.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } // createImportedAddressParams maps imported params to sqlc params. func createImportedAddressParams(accountID int64, - params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { - return sqlcsqlite.CreateImportedAddressParams{ + return sqlc.CreateImportedAddressParams{ AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int64(params.AddressType), @@ -242,22 +242,22 @@ func createImportedAddressParams(accountID int64, } // importedAddressRowID returns the created address ID. -func importedAddressRowID(row sqlcsqlite.CreateImportedAddressRow) int64 { +func importedAddressRowID(row sqlc.CreateImportedAddressRow) int64 { return row.ID } // importedAddressRowCreatedAt returns the CreatedAt timestamp. func importedAddressRowCreatedAt( - row sqlcsqlite.CreateImportedAddressRow) time.Time { + row sqlc.CreateImportedAddressRow) time.Time { return row.CreatedAt } // insertAddressSecretParams maps imported params to secret params. func insertAddressSecretParams(addressID int64, - params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlc.InsertAddressSecretParams { - return sqlcsqlite.InsertAddressSecretParams{ + return sqlc.InsertAddressSecretParams{ AddressID: addressID, EncryptedPrivKey: params.EncryptedPrivateKey, EncryptedScript: params.EncryptedScript, @@ -267,7 +267,7 @@ func insertAddressSecretParams(addressID int64, // addressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. func addressSecretRowToSecret( - row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { + row sqlc.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, @@ -280,8 +280,8 @@ func addressSecretRowToSecret( // address row types that share the same field structure. This enables a // single generic conversion function to handle all address query result types. type addressInfoRow interface { - sqlcsqlite.GetAddressByScriptPubKeyRow | - sqlcsqlite.ListAddressesByAccountRow + sqlc.GetAddressByScriptPubKeyRow | + sqlc.ListAddressesByAccountRow } // addressRowToInfo converts a SQLite address row to an AddressInfo @@ -290,7 +290,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) + base := sqlc.GetAddressByScriptPubKeyRow(row) info, err := db.AddressRowToInfo(db.AddressInfoRow[int64, int64]{ ID: base.ID, @@ -316,7 +316,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, // listAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. -func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlc.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( @@ -344,9 +344,9 @@ func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, // buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func buildAddressPageParams( - q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlc.ListAddressesByAccountParams { - params := sqlcsqlite.ListAddressesByAccountParams{ + params := sqlc.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index 7921ebc41a..92e462f8db 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -16,7 +16,7 @@ import ( func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) deleteOps := deleteTxOps{qtx: qtx} rollbackOps := rollbackToBlockOps{qtx: qtx} @@ -45,7 +45,7 @@ func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { func TestTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) createOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index 19bd983256..f26c1f215f 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -21,7 +21,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() loadOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -33,7 +33,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { block := testBlock(8) confirmOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow( t, "SELECT ?, ?, ?", @@ -53,7 +53,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { prepareOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -65,7 +65,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), @@ -79,7 +79,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() - ops := &releaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + ops := &releaseOutputOps{qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), })} @@ -99,15 +99,15 @@ func TestUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &updateTxOps{ - qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + qtx: sqlc.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt64{}, status: int64(db.TxStatusPublished), } - labelOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlc.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 2fc2b7fedb..1c4ddc78e4 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -6,9 +6,9 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildBlock constructs a Block from the given SQLite block @@ -26,12 +26,12 @@ func buildBlock(height sql.NullInt64, hash []byte, // ensureBlockExists ensures that a block exists in the database. If it // doesn't exist, it inserts it. -func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlc.Queries, block *db.Block) error { height := int64(block.Height) - blockParams := sqlcsqlite.InsertBlockParams{ + blockParams := sqlc.InsertBlockParams{ BlockHeight: height, HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -47,7 +47,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, // requireBlockMatches loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. -func requireBlockMatches(ctx context.Context, qtx *sqlcsqlite.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlc.Queries, block *db.Block) (int64, error) { height := int64(block.Height) diff --git a/wallet/internal/db/sqlite/config.go b/wallet/internal/db/sqlite/config.go new file mode 100644 index 0000000000..f9aba8086e --- /dev/null +++ b/wallet/internal/db/sqlite/config.go @@ -0,0 +1,26 @@ +package sqlite + +import db "github.com/btcsuite/btcwallet/wallet/internal/db" + +// Config holds the configuration for the SQLite database. +type Config struct { + // DBPath is the filesystem path to the SQLite database file. + DBPath string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use db.DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the Config values are valid. +func (c *Config) Validate() error { + if c.DBPath == "" { + return db.ErrEmptyDBPath + } + + if c.MaxConnections < 0 { + return db.ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/sqlite/config_test.go b/wallet/internal/db/sqlite/config_test.go new file mode 100644 index 0000000000..c3dc9c7e4c --- /dev/null +++ b/wallet/internal/db/sqlite/config_test.go @@ -0,0 +1,79 @@ +package sqlite + +import ( + "testing" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestConfigValidateSuccess tests valid Config scenarios. +func TestConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + }{ + { + name: "valid config with zero max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: 0, + }, + }, + { + name: "valid config with positive max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: 10, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestConfigValidateErrors tests Config validation errors. +func TestConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + expectedErr error + }{ + { + name: "empty DB path", + config: Config{ + DBPath: "", + MaxConnections: 0, + }, + expectedErr: db.ErrEmptyDBPath, + }, + { + name: "negative max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + expectedErr: db.ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} diff --git a/wallet/internal/db/sqlite/doc.go b/wallet/internal/db/sqlite/doc.go new file mode 100644 index 0000000000..655a60f0de --- /dev/null +++ b/wallet/internal/db/sqlite/doc.go @@ -0,0 +1,2 @@ +// Package sqlite implements the SQLite wallet store backend. +package sqlite diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 16144c37a7..9069ae7af7 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,15 +5,15 @@ package sqlite import ( "database/sql" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. -func (s *SqliteStore) DB() *sql.DB { +func (s *Store) DB() *sql.DB { return s.db } // Queries returns the underlying sqlc queries for integration testing. -func (s *SqliteStore) Queries() *sqlcsqlite.Queries { +func (s *Store) Queries() *sqlc.Queries { return s.queries } diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index 5fce98f1e7..6f08363d82 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -7,22 +7,21 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) -// SqliteStore is the SQLite implementation of the WalletStore interface. - -type SqliteStore struct { +// Store is the SQLite implementation of the WalletStore interface. +type Store struct { db *sql.DB - queries *sqlcsqlite.Queries + queries *sqlc.Queries } -// NewSqliteStore creates a new SQLite-based WalletStore. It handles the full +// NewStore creates a new SQLite-based WalletStore. It handles the full // connection setup including DSN construction with pragmas, connection // opening, health checks, connection pool configuration, and migration // application. -func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, +func NewStore(ctx context.Context, cfg Config) (*Store, error) { err := cfg.Validate() @@ -59,7 +58,7 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, dbConn.SetMaxIdleConns(maxConns) dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcsqlite.New(dbConn) + queries := sqlc.New(dbConn) err = sqlassetsqlite.ApplyMigrations(dbConn) if err != nil { @@ -67,14 +66,14 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, return nil, fmt.Errorf("apply migrations: %w", err) } - return &SqliteStore{ + return &Store{ db: dbConn, queries: queries, }, nil } // Close closes the database connection. -func (s *SqliteStore) Close() error { +func (s *Store) Close() error { err := s.db.Close() if err != nil { return fmt.Errorf("close database: %w", err) @@ -87,8 +86,8 @@ func (s *SqliteStore) Close() error { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (s *SqliteStore) ExecuteTx(ctx context.Context, - fn func(*sqlcsqlite.Queries) error) error { +func (s *Store) ExecuteTx(ctx context.Context, + fn func(*sqlc.Queries) error) error { return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 105a801002..28db65c5bd 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -5,11 +5,11 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned @@ -21,7 +21,7 @@ import ( // Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. -func (s *SqliteStore) CreateTx(ctx context.Context, +func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { req, err := db.NewCreateTxRequest(params) @@ -29,7 +29,7 @@ func (s *SqliteStore) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, @@ -53,7 +53,7 @@ func (o *createTxOps) LoadExisting(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcsqlite.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -90,7 +90,7 @@ func (o *createTxOps) ConfirmExisting(ctx context.Context, } rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, Status: int64(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), @@ -154,7 +154,7 @@ func (o *createTxOps) ListConflictTxns(ctx context.Context, // collectConflictRootIDs returns the active unmined spender row // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectConflictRootIDs(ctx context.Context, - qtx *sqlcsqlite.Queries, + qtx *sqlc.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -164,7 +164,7 @@ func collectConflictRootIDs(ctx context.Context, rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) for inputIndex, txIn := range req.Params.Tx.TxIn { spentByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), @@ -191,7 +191,7 @@ func collectConflictRootIDs(ctx context.Context, // buildConflictRoots maps the selected unmined rows into ordered root IDs // and the matching root hashes used for descendant discovery. -func buildConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlc.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -221,7 +221,7 @@ func (o *createTxOps) Insert(ctx context.Context, txID, err := o.qtx.InsertTransaction( ctx, - sqlcsqlite.InsertTransactionParams{ + sqlc.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], RawTx: req.RawTx, @@ -259,7 +259,7 @@ func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusReplaced), TxIds: txIDs, @@ -280,7 +280,7 @@ func (o *createTxOps) InsertReplacementEdges( for _, replacedTxID := range replacedTxIDs { _, err := o.qtx.InsertTxReplacementEdge( - ctx, sqlcsqlite.InsertTxReplacementEdgeParams{ + ctx, sqlc.InsertTxReplacementEdgeParams{ WalletID: walletID, ReplacedTxID: replacedTxID, ReplacementTxID: replacementTxID, @@ -297,7 +297,7 @@ func (o *createTxOps) InsertReplacementEdges( // insertCredits inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, +func insertCredits(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { @@ -315,7 +315,7 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, pkScript := params.Tx.TxOut[index].PkScript addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlcsqlite.GetAddressByScriptPubKeyParams{ + ctx, sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: pkScript, WalletID: int64(params.WalletID), }, @@ -329,7 +329,7 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("resolve credit address %d: %w", index, err) } - _, err = qtx.InsertUtxo(ctx, sqlcsqlite.InsertUtxoParams{ + _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ WalletID: int64(params.WalletID), TxID: txID, OutputIndex: int64(index), @@ -346,11 +346,11 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, // creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, +func creditExists(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { _, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: int64(outputIndex), @@ -376,7 +376,7 @@ func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -387,7 +387,7 @@ func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, spentInputIndex := sql.NullInt64{Int64: int64(inputIndex), Valid: true} rowsAffected, err := qtx.MarkUtxoSpent(ctx, - sqlcsqlite.MarkUtxoSpentParams{ + sqlc.MarkUtxoSpentParams{ WalletID: int64(params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), @@ -417,11 +417,11 @@ func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, // to another transaction. If the wallet owns the parent output but that parent // is already invalid, the helper returns ErrTxInputInvalidParent instead. func ensureSpendConflict(ctx context.Context, - qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64, txID int64) error { spendByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, @@ -448,11 +448,11 @@ func ensureSpendConflict(ctx context.Context, // wallet owns the referenced outpoint but its parent transaction is already // invalid. func ensureWalletParentValid(ctx context.Context, - qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( - ctx, sqlcsqlite.HasInvalidWalletUtxoByOutpointParams{ + ctx, sqlc.HasInvalidWalletUtxoByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index b412d9798d..1cbd388095 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet @@ -18,17 +18,17 @@ import ( // terminal invalid-history rows remain part of the wallet timeline. The // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. -func (s *SqliteStore) DeleteTx(ctx context.Context, +func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } // deleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. type deleteTxOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.DeleteTxOps = (*deleteTxOps)(nil) @@ -61,7 +61,7 @@ func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, _, err := o.qtx.ClearUtxosSpentByTxID( ctx, - sqlcsqlite.ClearUtxosSpentByTxIDParams{ + sqlc.ClearUtxosSpentByTxIDParams{ WalletID: int64(walletID), SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, }, @@ -80,7 +80,7 @@ func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, _, err := o.qtx.DeleteUtxosByTxID( ctx, - sqlcsqlite.DeleteUtxosByTxIDParams{ + sqlc.DeleteUtxosByTxIDParams{ WalletID: int64(walletID), TxID: txID, }, @@ -99,7 +99,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, rows, err := o.qtx.DeleteUnminedTransactionByHash( ctx, - sqlcsqlite.DeleteUnminedTransactionByHashParams{ + sqlc.DeleteUnminedTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -114,7 +114,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, // ensureDeleteLeaf rejects DeleteTx requests for transactions that still // have direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -124,7 +124,7 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, candidates, err := db.BuildUnminedTxRecords( rows, - func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -153,33 +153,33 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, // getDeleteTxMeta loads the transaction metadata DeleteTx needs and // enforces the unmined precondition up front. -func getDeleteTxMeta(ctx context.Context, qtx *sqlcsqlite.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash) ( - sqlcsqlite.GetTransactionMetaByHashRow, error) { + sqlc.GetTransactionMetaByHashRow, error) { meta, err := qtx.GetTransactionMetaByHash( - ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { - return sqlcsqlite.GetTransactionMetaByHashRow{}, err + return sqlc.GetTransactionMetaByHashRow{}, err } if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, db.ErrDeleteRequiresUnmined) } diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 56122872f4..955d0a71a5 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. // // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. -func (s *SqliteStore) GetTx(ctx context.Context, +func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( - ctx, sqlcsqlite.GetTransactionByHashParams{ + ctx, sqlc.GetTransactionByHashParams{ WalletID: int64(query.WalletID), TxHash: query.Txid[:], }, diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 1671b9d150..4938dea170 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -5,18 +5,18 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. -func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, +func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) @@ -26,7 +26,7 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, // invalidateUnminedTxOps adapts sqlite sqlc queries to the shared // InvalidateUnminedTx workflow. type invalidateUnminedTxOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) @@ -38,7 +38,7 @@ func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( - ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -79,7 +79,7 @@ func (o invalidateUnminedTxOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords( - rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( + rows, func(row sqlc.ListUnminedTransactionsRow) ( int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -93,7 +93,7 @@ func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: txID, @@ -114,7 +114,7 @@ func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusFailed), TxIds: txIDs, diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index f656805c5c..bcfbe12bc3 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range @@ -15,7 +15,7 @@ import ( // The no-confirming-block path returns every row without a confirming block, // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. -func (s *SqliteStore) ListTxns(ctx context.Context, +func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { @@ -29,7 +29,7 @@ func (s *SqliteStore) ListTxns(ctx context.Context, // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, +func (s *Store) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -55,11 +55,11 @@ func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. -func (s *SqliteStore) listConfirmedTxns(ctx context.Context, +func (s *Store) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( - ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ + ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: int64(query.StartHeight), EndHeight: int64(query.EndHeight), diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index 3495c69eb1..ae48e12c14 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -4,19 +4,19 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided // height and rewrites wallet sync-state references so the block delete can // succeed. -func (s *SqliteStore) RollbackToBlock(ctx context.Context, +func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) @@ -25,7 +25,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, // rollbackToBlockOps adapts sqlite sqlc queries to the shared rollback // sequence. type rollbackToBlockOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) @@ -54,7 +54,7 @@ func (o rollbackToBlockOps) RewindWalletSyncStateHeights( } _, err := o.qtx.RewindWalletSyncStateHeightsForRollback( - ctx, sqlcsqlite.RewindWalletSyncStateHeightsForRollbackParams{ + ctx, sqlc.RewindWalletSyncStateHeightsForRollbackParams{ RollbackHeight: int64(height), NewHeight: newHeight, }, @@ -91,7 +91,7 @@ func (o rollbackToBlockOps) MarkTxRootsOrphaned( // block reference and become orphaned in the same // row-local state patch. rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{}, Status: int64(db.TxStatusOrphaned), WalletID: int64(walletID), @@ -121,7 +121,7 @@ func (o rollbackToBlockOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcsqlite.ListUnminedTransactionsRow) ( + func(row sqlc.ListUnminedTransactionsRow) ( int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -135,7 +135,7 @@ func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: descendantID, @@ -156,7 +156,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusFailed), TxIds: descendantIDs, @@ -172,7 +172,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( // groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRoots( - rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( + rows []sqlc.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 5c4dbad320..eb833407e9 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. @@ -17,10 +17,10 @@ import ( // one SQL transaction. Immutable transaction facts such as raw_tx, credits, and // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. -func (s *SqliteStore) UpdateTx(ctx context.Context, +func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } @@ -28,7 +28,7 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, // updateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. type updateTxOps struct { // qtx is the transaction-scoped sqlite query set used by UpdateTx. - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries // blockHeight caches the validated sqlite block-height wrapper prepared for // the later state update query. @@ -48,7 +48,7 @@ func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcsqlite.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -92,7 +92,7 @@ func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionLabelByHash( ctx, - sqlcsqlite.UpdateTransactionLabelByHashParams{ + sqlc.UpdateTransactionLabelByHashParams{ Label: label, WalletID: int64(walletID), TxHash: txHash[:], @@ -116,7 +116,7 @@ func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, - sqlcsqlite.UpdateTransactionStateByHashParams{ + sqlc.UpdateTransactionStateByHashParams{ BlockHeight: o.blockHeight, Status: o.status, WalletID: int64(walletID), diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index 1ec9cc852b..4caeb5fc12 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -3,20 +3,20 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil/v2" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. -func (s *SqliteStore) Balance(ctx context.Context, +func (s *Store) Balance(ctx context.Context, params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ + balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(params.Account), diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index f0eb24dbdb..d5bc3b60cf 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. // // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. -func (s *SqliteStore) GetUtxo(ctx context.Context, +func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlcsqlite.GetUtxoByOutpointParams{ + ctx, sqlc.GetUtxoByOutpointParams{ WalletID: int64(query.WalletID), TxHash: query.OutPoint.Hash[:], OutputIndex: int64(query.OutPoint.Index), diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 19849dc232..cbe2c2b3b2 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. @@ -16,12 +16,12 @@ import ( // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. -func (s *SqliteStore) LeaseOutput(ctx context.Context, +func (s *Store) LeaseOutput(ctx context.Context, params db.LeaseOutputParams) (*db.LeasedOutput, error) { var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) @@ -43,7 +43,7 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, // leaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput // workflow. type leaseOutputOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) @@ -55,7 +55,7 @@ func (o *leaseOutputOps) Acquire(ctx context.Context, expiresAt time.Time) (time.Time, error) { expiration, err := o.qtx.AcquireUtxoLease( - ctx, sqlcsqlite.AcquireUtxoLeaseParams{ + ctx, sqlc.AcquireUtxoLeaseParams{ WalletID: int64(params.WalletID), LockID: params.ID[:], ExpiresAt: expiresAt, @@ -81,7 +81,7 @@ func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: int64(params.OutPoint.Index), diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index b4f1b70d68..68705d245d 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -3,20 +3,20 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. -func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, +func (s *Store) ListLeasedOutputs(ctx context.Context, walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlcsqlite.ListActiveUtxoLeasesParams{ + ctx, sqlc.ListActiveUtxoLeasesParams{ WalletID: int64(walletID), NowUtc: nowUTC, }, diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 433c4416fd..03c1d082d2 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -3,19 +3,19 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. // // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. -func (s *SqliteStore) ListUTXOs(ctx context.Context, +func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ + rows, err := s.queries.ListUtxos(ctx, sqlc.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index a7f9a0fc48..5cfad1d1e6 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the @@ -16,10 +16,10 @@ import ( // // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. -func (s *SqliteStore) ReleaseOutput(ctx context.Context, +func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) @@ -29,7 +29,7 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, // releaseOutputOps adapts sqlite sqlc queries to the shared // ReleaseOutput workflow. type releaseOutputOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) @@ -40,7 +40,7 @@ func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: int64(params.OutPoint.Index), @@ -63,7 +63,7 @@ func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( - ctx, sqlcsqlite.ReleaseUtxoLeaseParams{ + ctx, sqlc.ReleaseUtxoLeaseParams{ WalletID: int64(walletID), UtxoID: utxoID, LockID: lockID[:], @@ -82,7 +82,7 @@ func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( - ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ + ctx, sqlc.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, NowUtc: nowUTC, diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 51f582534f..eaf0f9fd85 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// Ensure SqliteStore satisfies the WalletStore interface. -var _ db.WalletStore = (*SqliteStore)(nil) +// Ensure Store satisfies the WalletStore interface. +var _ db.WalletStore = (*Store)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (s *SqliteStore) CreateWallet(ctx context.Context, +func (s *Store) CreateWallet(ctx context.Context, params db.CreateWalletParams) (*db.WalletInfo, error) { var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - walletParams := sqlcsqlite.CreateWalletParams{ + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: int64(params.ManagerVersion), @@ -39,7 +39,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, return fmt.Errorf("create wallet: %w", err) } - secretsParams := sqlcsqlite.InsertWalletSecretsParams{ + secretsParams := sqlc.InsertWalletSecretsParams{ WalletID: id, MasterPrivParams: params.MasterKeyPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, @@ -63,7 +63,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, } } - syncParams := sqlcsqlite.InsertWalletSyncStateParams{ + syncParams := sqlc.InsertWalletSyncStateParams{ WalletID: id, SyncedHeight: sql.NullInt64{}, BirthdayHeight: sql.NullInt64{}, @@ -114,7 +114,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (s *SqliteStore) GetWallet(ctx context.Context, +func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) @@ -144,7 +144,7 @@ func (s *SqliteStore) GetWallet(ctx context.Context, } // ListWallets returns a page of wallets matching the given query. -func (s *SqliteStore) ListWallets(ctx context.Context, +func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( @@ -177,7 +177,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, } // IterWallets returns an iterator over paginated wallet results. -func (s *SqliteStore) IterWallets(ctx context.Context, +func (s *Store) IterWallets(ctx context.Context, query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( @@ -189,10 +189,10 @@ func (s *SqliteStore) IterWallets(ctx context.Context, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (s *SqliteStore) UpdateWallet(ctx context.Context, +func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists( @@ -234,7 +234,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, +func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) @@ -257,10 +257,10 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, +func (s *Store) UpdateWalletSecrets(ctx context.Context, params db.UpdateWalletSecretsParams) error { - secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ + secretsParams := sqlc.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, @@ -301,7 +301,7 @@ type walletRowParams struct { // listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. func listWalletRowToInfo( - row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { + row sqlc.ListWalletsRow) (*db.WalletInfo, error) { return buildWalletInfo(walletRowParams{ id: row.ID, @@ -322,9 +322,9 @@ func listWalletRowToInfo( // listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. func listWalletsParams( - req page.Request[uint32]) sqlcsqlite.ListWalletsParams { + req page.Request[uint32]) sqlc.ListWalletsParams { - params := sqlcsqlite.ListWalletsParams{ + params := sqlc.ListWalletsParams{ PageLimit: int64(req.QueryLimit()), } @@ -392,9 +392,9 @@ func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { // buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParams( - params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + params db.UpdateWalletParams) sqlc.UpdateWalletSyncStateParams { - syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + syncParams := sqlc.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index ee34c55681..bdc3cbbdf9 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresStore and SqliteStore. It guarantees that the transaction +// PostgresStore and sqlite.Store. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) From 0790eb918cd5a547a6e093927bdc5ea72193f625 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:04:13 +0800 Subject: [PATCH 229/695] db/pg: rename exported store API Rename the pg backend's exported store API to the package-local Store, Config, and NewStore names now that the backend lives in its own package. The postgres config type and tests also move into the pg package so callers import the backend package for its concrete constructor surface. This removes package stutter at pg call sites while keeping the shared db package focused on backend-neutral helpers and errors. --- wallet/internal/db/config.go | 33 ------ wallet/internal/db/db_connectors_test.go | 16 +-- wallet/internal/db/itest/fixtures_pg_test.go | 34 +++--- wallet/internal/db/itest/pg_test.go | 30 ++--- .../db/itest/tx_corruption_pg_test.go | 11 +- .../db/itest/tx_store_corruption_test.go | 4 +- .../db/itest/utxo_store_edge_cases_test.go | 2 +- wallet/internal/db/pg/accounts.go | 88 +++++++------- wallet/internal/db/pg/address_types.go | 10 +- wallet/internal/db/pg/addresses.go | 108 +++++++++--------- wallet/internal/db/pg/backend_error_test.go | 10 +- wallet/internal/db/pg/backend_rows_test.go | 16 +-- wallet/internal/db/pg/block.go | 10 +- wallet/internal/db/pg/config.go | 36 ++++++ wallet/internal/db/{ => pg}/config_test.go | 29 ++--- wallet/internal/db/pg/doc.go | 2 + wallet/internal/db/pg/itest.go | 9 +- wallet/internal/db/pg/store.go | 23 ++-- wallet/internal/db/pg/testhelpers_test.go | 9 -- wallet/internal/db/pg/txstore_createtx.go | 46 ++++---- wallet/internal/db/pg/txstore_deletetx.go | 34 +++--- wallet/internal/db/pg/txstore_gettx.go | 8 +- .../db/pg/txstore_invalidateunmined.go | 26 +++-- wallet/internal/db/pg/txstore_listtxns.go | 12 +- wallet/internal/db/pg/txstore_rollback.go | 22 ++-- wallet/internal/db/pg/txstore_updatetx.go | 16 +-- wallet/internal/db/pg/utxostore_balance.go | 8 +- wallet/internal/db/pg/utxostore_getutxo.go | 8 +- .../internal/db/pg/utxostore_leaseoutput.go | 14 +-- .../db/pg/utxostore_listleasedoutputs.go | 8 +- wallet/internal/db/pg/utxostore_listutxos.go | 10 +- .../internal/db/pg/utxostore_releaseoutput.go | 16 +-- wallet/internal/db/pg/wallet.go | 42 +++---- wallet/internal/db/tx.go | 2 +- 34 files changed, 376 insertions(+), 376 deletions(-) create mode 100644 wallet/internal/db/pg/config.go rename wallet/internal/db/{ => pg}/config_test.go (70%) create mode 100644 wallet/internal/db/pg/doc.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go index 43363a14ab..27f6a9826b 100644 --- a/wallet/internal/db/config.go +++ b/wallet/internal/db/config.go @@ -2,10 +2,7 @@ package db import ( "errors" - "fmt" "time" - - "github.com/jackc/pgx/v5" ) const ( @@ -35,33 +32,3 @@ var ( // ErrEmptyDSN is returned when the DSN string is empty. ErrEmptyDSN = errors.New("DSN is required") ) - -// PostgresConfig holds the configuration for the PostgreSQL database. -type PostgresConfig struct { - // Dsn is the database connection string. - Dsn string - - // MaxConnections is the maximum number of open connections to the - // database. Set to zero to use DefaultMaxConnections. - MaxConnections int -} - -// Validate checks that the PostgresConfig values are valid. -func (c *PostgresConfig) Validate() error { - if c.Dsn == "" { - return ErrEmptyDSN - } - - // Parse the DSN using pgx to ensure it's a valid PostgreSQL - // connection string. - _, err := pgx.ParseConfig(c.Dsn) - if err != nil { - return fmt.Errorf("invalid DSN: %w", err) - } - - if c.MaxConnections < 0 { - return ErrNegativeMaxConns - } - - return nil -} diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 49f1dcdf1a..9185bf8d74 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -10,24 +10,24 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewPostgresStoreValidateConfig(t *testing.T) { +func TestPostgresNewStoreValidateConfig(t *testing.T) { t.Parallel() tests := []struct { name string - cfg db.PostgresConfig + cfg dbpg.Config wantErr error }{ { name: "empty DSN", - cfg: db.PostgresConfig{ + cfg: dbpg.Config{ Dsn: "", }, wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: db.PostgresConfig{ + cfg: dbpg.Config{ Dsn: "postgres://test", MaxConnections: -1, }, @@ -39,22 +39,22 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbpg.NewPostgresStore(t.Context(), tc.cfg) + store, err := dbpg.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) } } -func TestNewPostgresStoreConnectionFailure(t *testing.T) { +func TestPostgresNewStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := db.PostgresConfig{ + cfg := dbpg.Config{ Dsn: "postgres://localhost:1/testdb", } - store, err := dbpg.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 540d1443ac..924e1ae3c5 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -11,18 +11,18 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) // CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, +func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { t.Helper() block := NewBlockFixture(height) err := queries.InsertBlock( - t.Context(), sqlcpg.InsertBlockParams{ + t.Context(), sqlc.InsertBlockParams{ BlockHeight: int32(block.Height), HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -35,12 +35,12 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, // CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, +func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, scopeID int64, accountNumber uint32, name string) { t.Helper() _, err := queries.CreateDerivedAccountWithNumber( - t.Context(), sqlcpg.CreateDerivedAccountWithNumberParams{ + t.Context(), sqlc.CreateDerivedAccountWithNumberParams{ ScopeID: scopeID, AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, AccountName: name, @@ -54,12 +54,12 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. -func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, +func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( - t.Context(), sqlcpg.CreateDerivedAddressParams{ + t.Context(), sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), @@ -99,12 +99,12 @@ func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, } // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. -func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, +func GetKeyScopeID(t *testing.T, queries *sqlc.Queries, walletID uint32, scope db.KeyScope) int64 { t.Helper() row, err := queries.GetKeyScopeByWalletAndScope( - t.Context(), sqlcpg.GetKeyScopeByWalletAndScopeParams{ + t.Context(), sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -116,13 +116,13 @@ func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, } // GetAccountID retrieves the account ID for a given scope and account name. -func GetAccountID(t *testing.T, queries *sqlcpg.Queries, +func GetAccountID(t *testing.T, queries *sqlc.Queries, scopeID int64, accountName string) int64 { t.Helper() row, err := queries.GetAccountByScopeAndName( t.Context(), - sqlcpg.GetAccountByScopeAndNameParams{ + sqlc.GetAccountByScopeAndNameParams{ ScopeID: scopeID, AccountName: accountName, }, @@ -132,12 +132,12 @@ func GetAccountID(t *testing.T, queries *sqlcpg.Queries, return row.ID } -func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, +func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, walletID uint32) int64 { t.Helper() addr, err := queries.GetAddressByScriptPubKey( - t.Context(), sqlcpg.GetAddressByScriptPubKeyParams{ + t.Context(), sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: scriptPubKey, WalletID: int64(walletID), }, @@ -147,8 +147,8 @@ func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlcpg.Queries, - addressID int64) (sqlcpg.GetAddressSecretRow, error) { +func GetAddressSecret(t *testing.T, queries *sqlc.Queries, + addressID int64) (sqlc.GetAddressSecretRow, error) { t.Helper() return queries.GetAddressSecret(t.Context(), addressID) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbpg.PostgresStore{}, store) + require.IsType(t, &dbpg.Store{}, store) - pgStore := store.(*dbpg.PostgresStore) + pgStore := store.(*dbpg.Store) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 9cbcce1800..92a4a962a8 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -21,7 +21,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -193,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *dbpg.PostgresStore { +func NewTestStore(t *testing.T) *dbpg.Store { t.Helper() ctx := t.Context() @@ -223,12 +223,12 @@ func NewTestStore(t *testing.T) *dbpg.PostgresStore { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - cfg := db.PostgresConfig{ + cfg := dbpg.Config{ Dsn: testConnStr, MaxConnections: 0, } - store, err := dbpg.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -240,14 +240,14 @@ func NewTestStore(t *testing.T) *dbpg.PostgresStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, +func childSpendingTxIDs(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -255,7 +255,7 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, require.NoError(t, err) childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( - t.Context(), sqlcpg.ListSpendingTxIDsByParentTxIDParams{ + t.Context(), sqlc.ListSpendingTxIDsByParentTxIDParams{ WalletID: int64(walletID), TxID: meta.ID, }, @@ -273,13 +273,13 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -297,13 +297,13 @@ func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() row, err := store.Queries().GetTransactionByHash( - t.Context(), sqlcpg.GetTransactionByHashParams{ + t.Context(), sqlc.GetTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -315,7 +315,7 @@ func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -324,7 +324,7 @@ func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, require.True(t, ok) rows, err := store.Queries().UpdateTransactionStatusByIDs( - t.Context(), sqlcpg.UpdateTransactionStatusByIDsParams{ + t.Context(), sqlc.UpdateTransactionStatusByIDsParams{ WalletID: int64(walletID), Status: int16(status), TxIds: []int64{txID}, @@ -336,14 +336,14 @@ func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbpg.PostgresStore, +func walletUtxoExists(t *testing.T, store *dbpg.Store, walletID uint32, outPoint wire.OutPoint) bool { t.Helper() _, err := store.Queries().GetUtxoIDByOutpoint( - t.Context(), sqlcpg.GetUtxoIDByOutpointParams{ + t.Context(), sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(walletID), TxHash: outPoint.Hash[:], OutputIndex: int32(outPoint.Index), diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index 8aa4b1c140..3e3eac72b0 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -20,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionStatus(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -50,7 +49,7 @@ func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -76,7 +75,7 @@ func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -111,7 +110,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -137,7 +136,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbpg.PostgresStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index d2f1bde364..9e88befe39 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -22,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*dbpg.PostgresStore); ok { + if _, ok := any(store).(*dbpg.Store); ok { stmt += " CASCADE" } @@ -492,7 +492,7 @@ func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. // PostgreSQL drops those dependent rows with CASCADE when the blocks table is // removed, so rollback gets far enough to fail on the block delete instead. - _, ok := any(store).(*db.PostgresStore) + _, ok := any(store).(*dbpg.Store) if ok { require.ErrorContains(t, err, "delete blocks at or above height") return diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index c07ba93604..2b264fddb1 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -258,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*dbpg.PostgresStore); ok { + if _, ok := any(store).(*dbpg.Store); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index cda543c54f..7e1503b789 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -4,27 +4,29 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// Ensure PostgresStore satisfies the AccountStore interface. -var _ db.AccountStore = (*PostgresStore)(nil) +// Ensure Store satisfies the AccountStore interface. +var _ db.AccountStore = (*Store)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (s *PostgresStore) GetAccount(ctx context.Context, +func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := accountGetQueries{q: s.queries} - return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (s *PostgresStore) ListAccounts(ctx context.Context, +func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := accountListQueries{q: s.queries} @@ -36,7 +38,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (s *PostgresStore) RenameAccount(ctx context.Context, +func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { renameQueries := accountRenameQueries{q: s.queries} @@ -49,7 +51,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, +func (s *Store) CreateDerivedAccount(ctx context.Context, params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() @@ -59,7 +61,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -78,7 +80,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, } row, err := qtx.CreateDerivedAccount( - ctx, sqlcpg.CreateDerivedAccountParams{ + ctx, sqlc.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int16(db.DerivedAccount), @@ -118,12 +120,12 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (s *PostgresStore) CreateImportedAccount(ctx context.Context, +func (s *Store) CreateImportedAccount(ctx context.Context, params db.CreateImportedAccountParams) (*db.AccountProperties, error) { var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( @@ -131,7 +133,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, return ensureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, buildCreateImportedAccountArgs(params), - func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, + func(row sqlc.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { return getAccountProps(ctx, qtx, accountID) @@ -151,12 +153,12 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, // CreateImportedAccountParams for PostgreSQL. func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, -) func(int64, bool) sqlcpg.CreateImportedAccountParams { +) func(int64, bool) sqlc.CreateImportedAccountParams { return func(scopeID int64, - isWatchOnly bool) sqlcpg.CreateImportedAccountParams { + isWatchOnly bool) sqlc.CreateImportedAccountParams { - return sqlcpg.CreateImportedAccountParams{ + return sqlc.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int16(db.ImportedAccount), @@ -174,10 +176,10 @@ func buildCreateImportedAccountArgs( // CreateAccountSecretParams for PostgreSQL. func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, -) func(int64) sqlcpg.CreateAccountSecretParams { +) func(int64) sqlc.CreateAccountSecretParams { - return func(accountID int64) sqlcpg.CreateAccountSecretParams { - return sqlcpg.CreateAccountSecretParams{ + return func(accountID int64) sqlc.CreateAccountSecretParams { + return sqlc.CreateAccountSecretParams{ AccountID: accountID, EncryptedPrivateKey: params.EncryptedPrivateKey, } @@ -186,7 +188,7 @@ func buildCreateAccountSecretArgs( // getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, +func getAccountProps(ctx context.Context, qtx *sqlc.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -216,18 +218,18 @@ func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, // ensureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. -func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, +func ensureKeyScope(ctx context.Context, qtx *sqlc.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, - sqlcpg.GetKeyScopeByWalletAndScopeParams{ + sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema db.ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { - return sqlcpg.CreateKeyScopeParams{ + func(addrSchema db.ScopeAddrSchema) sqlc.CreateKeyScopeParams { + return sqlc.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -240,7 +242,7 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ), } }, - func(row sqlcpg.KeyScope) int64 { + func(row sqlc.KeyScope) int64 { return row.ID }, scope, ) @@ -250,13 +252,13 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. type accountInfoRow interface { - sqlcpg.GetAccountByScopeAndNameRow | - sqlcpg.GetAccountByScopeAndNumberRow | - sqlcpg.GetAccountByWalletScopeAndNameRow | - sqlcpg.GetAccountByWalletScopeAndNumberRow | - sqlcpg.ListAccountsByWalletRow | - sqlcpg.ListAccountsByWalletScopeRow | - sqlcpg.ListAccountsByWalletAndNameRow + sqlc.GetAccountByScopeAndNameRow | + sqlc.GetAccountByScopeAndNumberRow | + sqlc.GetAccountByWalletScopeAndNameRow | + sqlc.GetAccountByWalletScopeAndNumberRow | + sqlc.ListAccountsByWalletRow | + sqlc.ListAccountsByWalletScopeRow | + sqlc.ListAccountsByWalletAndNameRow } // accountRowToInfo converts a PostgreSQL account row to an AccountInfo @@ -265,7 +267,7 @@ type accountInfoRow interface { func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcpg.GetAccountByScopeAndNameRow(row) + base := sqlc.GetAccountByScopeAndNameRow(row) return db.AccountRowToInfo(db.AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, @@ -284,7 +286,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // accountListQueries groups PostgreSQL account listing query methods. type accountListQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byScope lists accounts filtered by wallet ID and key scope. @@ -293,7 +295,7 @@ func (p accountListQueries) byScope(ctx context.Context, return db.ListAccounts( ctx, p.q.ListAccountsByWalletScope, - sqlcpg.ListAccountsByWalletScopeParams{ + sqlc.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -307,7 +309,7 @@ func (p accountListQueries) byName(ctx context.Context, return db.ListAccounts( ctx, p.q.ListAccountsByWalletAndName, - sqlcpg.ListAccountsByWalletAndNameParams{ + sqlc.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, }, accountRowToInfo, @@ -326,7 +328,7 @@ func (p accountListQueries) all(ctx context.Context, // accountGetQueries groups PostgreSQL account retrieval query methods. type accountGetQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. @@ -335,7 +337,7 @@ func (p accountGetQueries) byNumber(ctx context.Context, return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, - sqlcpg.GetAccountByWalletScopeAndNumberParams{ + sqlc.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -350,7 +352,7 @@ func (p accountGetQueries) byName(ctx context.Context, return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, - sqlcpg.GetAccountByWalletScopeAndNameParams{ + sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -361,7 +363,7 @@ func (p accountGetQueries) byName(ctx context.Context, // accountRenameQueries groups PostgreSQL account rename query methods. type accountRenameQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byNumber renames an account identified by wallet ID, scope, and account @@ -371,7 +373,7 @@ func (p accountRenameQueries) byNumber(ctx context.Context, return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, - sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ + sqlc.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), @@ -388,7 +390,7 @@ func (p accountRenameQueries) byName(ctx context.Context, return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, - sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ + sqlc.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 85346c1f35..fbbc06f9bf 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -2,14 +2,14 @@ package pg import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // addressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { +func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) if err != nil { return db.AddressTypeInfo{}, err @@ -23,7 +23,7 @@ func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( +func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( @@ -33,7 +33,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (s *PostgresStore) GetAddressType(ctx context.Context, +func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { return db.GetAddressTypeByID( diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 2e9b13b736..b21b9fe96c 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -var _ db.AddressStore = (*PostgresStore)(nil) +var _ db.AddressStore = (*Store)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (s *PostgresStore) GetAddress(ctx context.Context, +func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q db.GetAddressQuery) (*db.AddressInfo, - error) { + getByScript := func(ctx context.Context, + q db.GetAddressQuery) (*db.AddressInfo, error) { return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, - sqlcpg.GetAddressByScriptPubKeyParams{ + sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), }, addressRowToInfo, @@ -35,7 +35,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, } // ListAddresses returns a page of addresses matching the given query. -func (s *PostgresStore) ListAddresses(ctx context.Context, +func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := listAddressesByAccount(ctx, s.queries, query) @@ -54,7 +54,7 @@ func (s *PostgresStore) ListAddresses(ctx context.Context, } // IterAddresses returns an iterator over paginated address results. -func (s *PostgresStore) IterAddresses(ctx context.Context, +func (s *Store) IterAddresses(ctx context.Context, query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( @@ -63,7 +63,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (s *PostgresStore) GetAddressSecret(ctx context.Context, +func (s *Store) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( @@ -73,15 +73,15 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. -func (s *PostgresStore) NewDerivedAddress(ctx context.Context, +func (s *Store) NewDerivedAddress(ctx context.Context, params db.NewDerivedAddressParams, deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ - *sqlcpg.Queries, - sqlcpg.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcpg.CreateDerivedAddressRow]{ + sqlc.CreateDerivedAddressRow]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, GetAccountID: derivedAddressGetAccountID, @@ -92,20 +92,22 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: derivedAddressRowCreatedAt, } - return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx( + ctx, params, s.ExecuteTx, adapters, deriveFn, + ) } // NewImportedAddress imports a new address, script, or private key. -func (s *PostgresStore) NewImportedAddress(ctx context.Context, +func (s *Store) NewImportedAddress(ctx context.Context, params db.NewImportedAddressParams) (*db.AddressInfo, error) { adapters := db.ImportedAddressAdapters[ - *sqlcpg.Queries, - sqlcpg.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcpg.CreateImportedAddressParams, - sqlcpg.CreateImportedAddressRow, - sqlcpg.InsertAddressSecretParams]{ + sqlc.CreateImportedAddressParams, + sqlc.CreateImportedAddressRow, + sqlc.InsertAddressSecretParams]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, GetAccountID: importedAddressGetAccountID, @@ -121,15 +123,15 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, } // getAccountFromKey returns a helper to look up accounts by key. -func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { +func getAccountFromKey(qtx *sqlc.Queries) func(context.Context, + db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( - ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ + ctx, sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: key.WalletID, Purpose: key.Purpose, CoinType: key.CoinType, @@ -141,43 +143,43 @@ func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, // derivedAddressGetAccountID extracts the account ID from a row. func derivedAddressGetAccountID( - row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // derivedAddressGetExtIndex returns the external index query. -func derivedAddressGetExtIndex(qtx *sqlcpg.Queries) func(context.Context, +func derivedAddressGetExtIndex(qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } // derivedAddressGetIntIndex returns the internal index query. -func derivedAddressGetIntIndex(qtx *sqlcpg.Queries) func(context.Context, +func derivedAddressGetIntIndex(qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } // derivedAddressCreateAddr returns the derived address insert helper. -func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, - int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, - error) { +func derivedAddressCreateAddr(qtx *sqlc.Queries) func( + context.Context, int64, db.AddressType, uint32, uint32, []byte, +) (sqlc.CreateDerivedAddressRow, error) { return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { branchNum, err := db.Uint32ToInt16(branch) if err != nil { - return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( + return sqlc.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, ) } return qtx.CreateDerivedAddress( - ctx, sqlcpg.CreateDerivedAddressParams{ + ctx, sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), @@ -196,44 +198,44 @@ func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, } // derivedAddressRowID returns the created address ID. -func derivedAddressRowID(row sqlcpg.CreateDerivedAddressRow) int64 { +func derivedAddressRowID(row sqlc.CreateDerivedAddressRow) int64 { return row.ID } // derivedAddressRowCreatedAt returns the CreatedAt timestamp. func derivedAddressRowCreatedAt( - row sqlcpg.CreateDerivedAddressRow) time.Time { + row sqlc.CreateDerivedAddressRow) time.Time { return row.CreatedAt } // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( - row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // createImportedAddress returns the imported address insert helper. -func createImportedAddress(qtx *sqlcpg.Queries) func(context.Context, - sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, +func createImportedAddress(qtx *sqlc.Queries) func(context.Context, + sqlc.CreateImportedAddressParams) (sqlc.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } // insertAddressSecret returns the secret insert helper. -func insertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, - sqlcpg.InsertAddressSecretParams) error { +func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, + sqlc.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } // createImportedAddressParams maps imported params to sqlc params. func createImportedAddressParams(accountID int64, - params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { - return sqlcpg.CreateImportedAddressParams{ + return sqlc.CreateImportedAddressParams{ AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int16(params.AddressType), @@ -243,9 +245,9 @@ func createImportedAddressParams(accountID int64, // insertAddressSecretParams maps imported params to secret params. func insertAddressSecretParams(addressID int64, - params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlc.InsertAddressSecretParams { - return sqlcpg.InsertAddressSecretParams{ + return sqlc.InsertAddressSecretParams{ AddressID: addressID, EncryptedPrivKey: params.EncryptedPrivateKey, EncryptedScript: params.EncryptedScript, @@ -253,13 +255,13 @@ func insertAddressSecretParams(addressID int64, } // importedAddressRowID returns the created address ID. -func importedAddressRowID(row sqlcpg.CreateImportedAddressRow) int64 { +func importedAddressRowID(row sqlc.CreateImportedAddressRow) int64 { return row.ID } // importedAddressRowCreatedAt returns the CreatedAt timestamp. func importedAddressRowCreatedAt( - row sqlcpg.CreateImportedAddressRow) time.Time { + row sqlc.CreateImportedAddressRow) time.Time { return row.CreatedAt } @@ -267,7 +269,7 @@ func importedAddressRowCreatedAt( // addressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. func addressSecretRowToSecret( - row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { + row sqlc.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, @@ -280,8 +282,8 @@ func addressSecretRowToSecret( // row types that share the same field structure. This enables a single // generic conversion function to handle all address query result types. type addressInfoRow interface { - sqlcpg.GetAddressByScriptPubKeyRow | - sqlcpg.ListAddressesByAccountRow + sqlc.GetAddressByScriptPubKeyRow | + sqlc.ListAddressesByAccountRow } // addressRowToInfo converts a PostgreSQL address row to an AddressInfo @@ -289,7 +291,7 @@ type addressInfoRow interface { func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcpg.GetAddressByScriptPubKeyRow(row) + base := sqlc.GetAddressByScriptPubKeyRow(row) info, err := db.AddressRowToInfo(db.AddressInfoRow[int16, int16]{ ID: base.ID, @@ -318,7 +320,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // listAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. -func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlc.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( @@ -346,9 +348,9 @@ func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, // buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func buildAddressPageParams( - q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlc.ListAddressesByAccountParams { - params := sqlcpg.ListAddressesByAccountParams{ + params := sqlc.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index f4583b5f03..9d3d03daab 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "errors" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) @@ -56,7 +56,7 @@ func (e errorDBTX) QueryRowContext(context.Context, string, func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcpg.New(errorDBTX{ + qtx := sqlc.New(errorDBTX{ execErr: errDummy, queryErr: errDummy, }) @@ -90,7 +90,7 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) createOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } @@ -208,7 +208,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { ) require.ErrorContains(t, err, "convert rollback height") - _, _, err = buildConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + _, _, err = buildConflictRoots([]sqlc.ListUnminedTransactionsRow{{ ID: 1, TxHash: []byte{1}, TxStatus: 0, diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 08505a2aed..bc86fd1494 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -3,12 +3,12 @@ package pg import ( "context" "database/sql" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) @@ -94,7 +94,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() loadOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -110,7 +110,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { } confirmOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow( t, "SELECT ?, ?, ?", int64(block.Height), block.Hash[:], @@ -128,7 +128,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), @@ -146,15 +146,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &updateTxOps{ - qtx: sqlcpg.New(rowDBTX{rows: 0}), + qtx: sqlc.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, status: int16(db.TxStatusPublished), } - labelOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlc.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 16d29c6476..8f906eb2f7 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -6,9 +6,9 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildBlock constructs a Block from the given PostgreSQL block @@ -25,7 +25,7 @@ func buildBlock(height sql.NullInt32, hash []byte, } // ensureBlockExists ensures that a block exists in the database. -func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlc.Queries, block *db.Block) error { height, err := db.Uint32ToInt32(block.Height) @@ -33,7 +33,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert block height: %w", err) } - blockParams := sqlcpg.InsertBlockParams{ + blockParams := sqlc.InsertBlockParams{ BlockHeight: height, HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -49,7 +49,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, // requireBlockMatches loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. -func requireBlockMatches(ctx context.Context, qtx *sqlcpg.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlc.Queries, block *db.Block) (int32, error) { height, err := db.Uint32ToInt32(block.Height) diff --git a/wallet/internal/db/pg/config.go b/wallet/internal/db/pg/config.go new file mode 100644 index 0000000000..3046c040c6 --- /dev/null +++ b/wallet/internal/db/pg/config.go @@ -0,0 +1,36 @@ +package pg + +import ( + "fmt" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/jackc/pgx/v5" +) + +// Config holds the configuration for the PostgreSQL database. +type Config struct { + // Dsn is the database connection string. + Dsn string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use db.DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the Config values are valid. +func (c *Config) Validate() error { + if c.Dsn == "" { + return db.ErrEmptyDSN + } + + _, err := pgx.ParseConfig(c.Dsn) + if err != nil { + return fmt.Errorf("invalid DSN: %w", err) + } + + if c.MaxConnections < 0 { + return db.ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/pg/config_test.go similarity index 70% rename from wallet/internal/db/config_test.go rename to wallet/internal/db/pg/config_test.go index 5981fba9a1..1cf5fdcf93 100644 --- a/wallet/internal/db/config_test.go +++ b/wallet/internal/db/pg/config_test.go @@ -1,29 +1,30 @@ -package db +package pg import ( "testing" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) -// TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. -func TestPostgresConfigValidateSuccess(t *testing.T) { +// TestConfigValidateSuccess tests valid Config scenarios. +func TestConfigValidateSuccess(t *testing.T) { t.Parallel() tests := []struct { name string - config PostgresConfig + config Config }{ { name: "valid config with all fields set", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://user:pass@localhost/db", MaxConnections: 25, }, }, { name: "valid config with zero max connections", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://localhost/db", MaxConnections: 0, }, @@ -40,27 +41,27 @@ func TestPostgresConfigValidateSuccess(t *testing.T) { } } -// TestPostgresConfigValidateErrors tests PostgresConfig validation errors. -func TestPostgresConfigValidateErrors(t *testing.T) { +// TestConfigValidateErrors tests Config validation errors. +func TestConfigValidateErrors(t *testing.T) { t.Parallel() tests := []struct { name string - config PostgresConfig + config Config expectedErr error expectAnyError bool }{ { name: "empty DSN", - config: PostgresConfig{ + config: Config{ Dsn: "", MaxConnections: 10, }, - expectedErr: ErrEmptyDSN, + expectedErr: db.ErrEmptyDSN, }, { name: "invalid DSN format", - config: PostgresConfig{ + config: Config{ Dsn: "://invalid", MaxConnections: 10, }, @@ -68,11 +69,11 @@ func TestPostgresConfigValidateErrors(t *testing.T) { }, { name: "negative max connections", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://localhost/db", MaxConnections: -5, }, - expectedErr: ErrNegativeMaxConns, + expectedErr: db.ErrNegativeMaxConns, }, } diff --git a/wallet/internal/db/pg/doc.go b/wallet/internal/db/pg/doc.go new file mode 100644 index 0000000000..9f65a8dc42 --- /dev/null +++ b/wallet/internal/db/pg/doc.go @@ -0,0 +1,2 @@ +// Package pg implements the PostgreSQL wallet store backend. +package pg diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index f9f9b1a441..2e66940b9b 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -1,20 +1,19 @@ //go:build itest -package db +package pg import ( "database/sql" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. -func (s *PostgresStore) DB() *sql.DB { +func (s *Store) DB() *sql.DB { return s.db } // Queries returns the underlying sqlc queries for integration testing. -func (s *PostgresStore) Queries() *sqlcpg.Queries { +func (s *Store) Queries() *sqlc.Queries { return s.queries } diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 7f0e131d73..2b7e4c3a0a 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -7,22 +7,21 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) -// PostgresStore is the PostgreSQL implementation of the +// Store is the PostgreSQL implementation of the // WalletStore interface. -type PostgresStore struct { +type Store struct { db *sql.DB - queries *sqlcpg.Queries + queries *sqlc.Queries } -// NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles +// NewStore creates a new PostgreSQL-based WalletStore. It handles // the full connection setup including config validation, connection opening, // health checks, connection pool configuration, and migration application. -func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStore, +func NewStore(ctx context.Context, cfg Config) (*Store, error) { err := cfg.Validate() @@ -53,7 +52,7 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor dbConn.SetMaxIdleConns(maxConns) dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcpg.New(dbConn) + queries := sqlc.New(dbConn) err = sqlassetpg.ApplyMigrations(dbConn) if err != nil { @@ -61,14 +60,14 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor return nil, fmt.Errorf("apply migrations: %w", err) } - return &PostgresStore{ + return &Store{ db: dbConn, queries: queries, }, nil } // Close closes the database connection. -func (s *PostgresStore) Close() error { +func (s *Store) Close() error { err := s.db.Close() if err != nil { return fmt.Errorf("close database: %w", err) @@ -81,8 +80,8 @@ func (s *PostgresStore) Close() error { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (s *PostgresStore) ExecuteTx(ctx context.Context, - fn func(*sqlcpg.Queries) error) error { +func (s *Store) ExecuteTx(ctx context.Context, + fn func(*sqlc.Queries) error) error { return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go index 9429524854..a1ada7fde5 100644 --- a/wallet/internal/db/pg/testhelpers_test.go +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -11,15 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -// testBlock builds one deterministic test block. -func testBlock(height uint32) *db.Block { - return &db.Block{ - Hash: chainhash.Hash{byte(height), 1, 2, 3}, - Height: height, - Timestamp: time.Unix(int64(height), 0), - } -} - // testRegularMsgTx builds one simple non-coinbase transaction fixture. func testRegularMsgTx() *wire.MsgTx { return &wire.MsgTx{ diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 60a516d534..e90f2706b9 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -5,11 +5,11 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its @@ -21,7 +21,7 @@ import ( // Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. -func (s *PostgresStore) CreateTx(ctx context.Context, +func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { req, err := db.NewCreateTxRequest(params) @@ -29,7 +29,7 @@ func (s *PostgresStore) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, @@ -53,7 +53,7 @@ func (o *createTxOps) LoadExisting(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcpg.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -90,7 +90,7 @@ func (o *createTxOps) ConfirmExisting(ctx context.Context, } rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcpg.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, Status: int16(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), @@ -153,7 +153,7 @@ func (o *createTxOps) ListConflictTxns(ctx context.Context, // collectConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. -func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, +func collectConflictRootIDs(ctx context.Context, qtx *sqlc.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -169,7 +169,7 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, } spentByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, @@ -196,7 +196,7 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, // buildConflictRoots maps the selected unmined rows into ordered root IDs and // the matching root hashes used for descendant discovery. -func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlc.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -224,7 +224,7 @@ func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { - txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ + txID, err := o.qtx.InsertTransaction(ctx, sqlc.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], RawTx: req.RawTx, @@ -261,7 +261,7 @@ func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusReplaced), TxIds: txIDs, @@ -282,7 +282,7 @@ func (o *createTxOps) InsertReplacementEdges( for _, replacedTxID := range replacedTxIDs { _, err := o.qtx.InsertTxReplacementEdge( - ctx, sqlcpg.InsertTxReplacementEdgeParams{ + ctx, sqlc.InsertTxReplacementEdgeParams{ WalletID: walletID, ReplacedTxID: replacedTxID, ReplacementTxID: replacementTxID, @@ -299,7 +299,7 @@ func (o *createTxOps) InsertReplacementEdges( // insertCredits inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, +func insertCredits(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { @@ -317,7 +317,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, pkScript := params.Tx.TxOut[index].PkScript addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlcpg.GetAddressByScriptPubKeyParams{ + ctx, sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: pkScript, WalletID: int64(params.WalletID), }, @@ -336,7 +336,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert credit index %d: %w", index, err) } - _, err = qtx.InsertUtxo(ctx, sqlcpg.InsertUtxoParams{ + _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ WalletID: int64(params.WalletID), TxID: txID, OutputIndex: outputIndex, @@ -353,7 +353,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, // creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExists(ctx context.Context, qtx *sqlcpg.Queries, +func creditExists(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { convertedIndex, err := db.Uint32ToInt32(outputIndex) @@ -363,7 +363,7 @@ func creditExists(ctx context.Context, qtx *sqlcpg.Queries, } _, err = qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: convertedIndex, @@ -389,7 +389,7 @@ func creditExists(ctx context.Context, qtx *sqlcpg.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -408,7 +408,7 @@ func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert input index %d: %w", inputIndex, err) } - rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlcpg.MarkUtxoSpentParams{ + rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlc.MarkUtxoSpentParams{ WalletID: int64(params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, @@ -437,12 +437,12 @@ func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, // is wallet-owned, still eligible for spending, and already attached to another // transaction. If the wallet owns the parent output but that parent is already // invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, +func ensureSpendConflict(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32, txID int64) error { spendByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, @@ -467,11 +467,11 @@ func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, // ensureWalletParentValid reports ErrTxInputInvalidParent when the wallet // owns the referenced outpoint but its parent transaction is already invalid. -func ensureWalletParentValid(ctx context.Context, qtx *sqlcpg.Queries, +func ensureWalletParentValid(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( - ctx, sqlcpg.HasInvalidWalletUtxoByOutpointParams{ + ctx, sqlc.HasInvalidWalletUtxoByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index a5b8efb968..7427a32f4e 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet @@ -18,17 +18,17 @@ import ( // terminal invalid-history rows remain part of the wallet timeline. The // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. -func (s *PostgresStore) DeleteTx(ctx context.Context, +func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } // deleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. type deleteTxOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.DeleteTxOps = (*deleteTxOps)(nil) @@ -61,7 +61,7 @@ func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, _, err := o.qtx.ClearUtxosSpentByTxID( ctx, - sqlcpg.ClearUtxosSpentByTxIDParams{ + sqlc.ClearUtxosSpentByTxIDParams{ WalletID: int64(walletID), SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, }, @@ -80,7 +80,7 @@ func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, _, err := o.qtx.DeleteUtxosByTxID( ctx, - sqlcpg.DeleteUtxosByTxIDParams{ + sqlc.DeleteUtxosByTxIDParams{ WalletID: int64(walletID), TxID: txID, }, @@ -99,7 +99,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, rows, err := o.qtx.DeleteUnminedTransactionByHash( ctx, - sqlcpg.DeleteUnminedTransactionByHashParams{ + sqlc.DeleteUnminedTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -114,7 +114,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, // ensureDeleteLeaf rejects DeleteTx requests for transactions that still have // direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -123,7 +123,7 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, } candidates, err := db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -150,33 +150,33 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, // getDeleteTxMeta loads the transaction metadata DeleteTx needs and enforces // the unmined precondition up front. -func getDeleteTxMeta(ctx context.Context, qtx *sqlcpg.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash) ( - sqlcpg.GetTransactionMetaByHashRow, error) { + sqlc.GetTransactionMetaByHashRow, error) { meta, err := qtx.GetTransactionMetaByHash( - ctx, sqlcpg.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { - return sqlcpg.GetTransactionMetaByHashRow{}, err + return sqlc.GetTransactionMetaByHashRow{}, err } if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, db.ErrDeleteRequiresUnmined) } diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index bb3c10dee4..50a117a789 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. // // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. -func (s *PostgresStore) GetTx(ctx context.Context, +func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( - ctx, sqlcpg.GetTransactionByHashParams{ + ctx, sqlc.GetTransactionByHashParams{ WalletID: int64(query.WalletID), TxHash: query.Txid[:], }, diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index 2e4fe1a964..ee26f79c32 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -5,18 +5,18 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. -func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, +func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) @@ -26,7 +26,7 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, // invalidateUnminedTxOps adapts postgres sqlc queries to the shared // InvalidateUnminedTx workflow. type invalidateUnminedTxOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) @@ -34,18 +34,20 @@ var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { + walletID uint32, + txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( - ctx, sqlcpg.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return db.InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - db.ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf( + "tx %s: %w", txHash, db.ErrTxNotFound, + ) } return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", @@ -77,7 +79,7 @@ func (o invalidateUnminedTxOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -89,7 +91,7 @@ func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: txID, @@ -110,7 +112,7 @@ func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusFailed), TxIds: txIDs, diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 8a37df6e42..c74bbb934f 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range @@ -15,7 +15,7 @@ import ( // The no-confirming-block path returns every row without a confirming block, // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. -func (s *PostgresStore) ListTxns(ctx context.Context, +func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { @@ -29,7 +29,7 @@ func (s *PostgresStore) ListTxns(ctx context.Context, // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, +func (s *Store) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -55,7 +55,7 @@ func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. -func (s *PostgresStore) listConfirmedTxns(ctx context.Context, +func (s *Store) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) @@ -69,7 +69,7 @@ func (s *PostgresStore) listConfirmedTxns(ctx context.Context, } rows, err := s.queries.ListTransactionsByHeightRange( - ctx, sqlcpg.ListTransactionsByHeightRangeParams{ + ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: startHeight, EndHeight: endHeight, diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index e0a8e14a15..e40822c41c 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -4,19 +4,19 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided // height and rewrites wallet sync-state references so the block delete can // succeed. -func (s *PostgresStore) RollbackToBlock(ctx context.Context, +func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) @@ -25,7 +25,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, // rollbackToBlockOps adapts postgres sqlc queries to the shared rollback // sequence. type rollbackToBlockOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) @@ -75,7 +75,7 @@ func (o rollbackToBlockOps) RewindWalletSyncStateHeights( } _, err = o.qtx.RewindWalletSyncStateHeightsForRollback( - ctx, sqlcpg.RewindWalletSyncStateHeightsForRollbackParams{ + ctx, sqlc.RewindWalletSyncStateHeightsForRollbackParams{ RollbackHeight: rollbackHeight, NewHeight: newHeight, }, @@ -116,7 +116,7 @@ func (o rollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // block reference and become orphaned in the same // row-local state patch. rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcpg.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{}, Status: int16(db.TxStatusOrphaned), WalletID: int64(walletID), @@ -146,7 +146,7 @@ func (o rollbackToBlockOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -158,7 +158,7 @@ func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: descendantID, @@ -179,7 +179,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusFailed), TxIds: descendantIDs, @@ -194,7 +194,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( // groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRoots(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( +func groupRollbackCoinbaseRoots(rows []sqlc.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 29b12d7b3b..039cd0e477 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chainhash/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. @@ -17,10 +17,10 @@ import ( // one SQL transaction. Immutable transaction facts such as raw_tx, credits, and // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. -func (s *PostgresStore) UpdateTx(ctx context.Context, +func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } @@ -28,7 +28,7 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, // updateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. type updateTxOps struct { // qtx is the transaction-scoped postgres query set used by UpdateTx. - qtx *sqlcpg.Queries + qtx *sqlc.Queries // blockHeight caches the validated postgres block-height wrapper prepared // for the later state update query. @@ -48,7 +48,7 @@ func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcpg.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -93,7 +93,7 @@ func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, - sqlcpg.UpdateTransactionStateByHashParams{ + sqlc.UpdateTransactionStateByHashParams{ BlockHeight: o.blockHeight, Status: o.status, WalletID: int64(walletID), @@ -117,7 +117,7 @@ func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionLabelByHash( ctx, - sqlcpg.UpdateTransactionLabelByHashParams{ + sqlc.UpdateTransactionLabelByHashParams{ Label: label, WalletID: int64(walletID), TxHash: txHash[:], diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 6f2ecd6aac..6ebc92e633 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -3,20 +3,20 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil/v2" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. -func (s *PostgresStore) Balance(ctx context.Context, +func (s *Store) Balance(ctx context.Context, params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ + balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(params.Account), diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index c12bad5a0c..03ecb4a8da 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -5,17 +5,17 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. // // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. -func (s *PostgresStore) GetUtxo(ctx context.Context, +func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { outputIndex, err := db.Uint32ToInt32(query.OutPoint.Index) @@ -24,7 +24,7 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, } row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlcpg.GetUtxoByOutpointParams{ + ctx, sqlc.GetUtxoByOutpointParams{ WalletID: int64(query.WalletID), TxHash: query.OutPoint.Hash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index d40a7cfdc7..211afd2123 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. @@ -16,12 +16,12 @@ import ( // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. -func (s *PostgresStore) LeaseOutput(ctx context.Context, +func (s *Store) LeaseOutput(ctx context.Context, params db.LeaseOutputParams) (*db.LeasedOutput, error) { var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) @@ -43,7 +43,7 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, // leaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput // workflow. type leaseOutputOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) @@ -60,7 +60,7 @@ func (o *leaseOutputOps) Acquire(ctx context.Context, } expiration, err := o.qtx.AcquireUtxoLease( - ctx, sqlcpg.AcquireUtxoLeaseParams{ + ctx, sqlc.AcquireUtxoLeaseParams{ WalletID: int64(params.WalletID), LockID: params.ID[:], ExpiresAt: expiresAt, @@ -91,7 +91,7 @@ func (o *leaseOutputOps) HasUtxo(ctx context.Context, } _, err = o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcpg.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 926175b55f..0d17605633 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -3,20 +3,20 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. -func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, +func (s *Store) ListLeasedOutputs(ctx context.Context, walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlcpg.ListActiveUtxoLeasesParams{ + ctx, sqlc.ListActiveUtxoLeasesParams{ WalletID: int64(walletID), NowUtc: nowUTC, }, diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 2f1f65e828..9673ca15bf 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -3,16 +3,16 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. // // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. -func (s *PostgresStore) ListUTXOs(ctx context.Context, +func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) @@ -38,8 +38,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, // buildListUtxosParams prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParams(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { - return sqlcpg.ListUtxosParams{ +func buildListUtxosParams(query db.ListUtxosQuery) sqlc.ListUtxosParams { + return sqlc.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), MinConfirms: db.NullableInt32ToSQLInt32(query.MinConfs), diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index ed87995902..0876102448 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the @@ -16,10 +16,10 @@ import ( // // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. -func (s *PostgresStore) ReleaseOutput(ctx context.Context, +func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) @@ -29,7 +29,7 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, // releaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput // workflow. type releaseOutputOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) @@ -45,7 +45,7 @@ func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, } utxoID, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcpg.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: outputIndex, @@ -68,7 +68,7 @@ func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( - ctx, sqlcpg.ReleaseUtxoLeaseParams{ + ctx, sqlc.ReleaseUtxoLeaseParams{ WalletID: int64(walletID), UtxoID: utxoID, LockID: lockID[:], @@ -87,7 +87,7 @@ func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( - ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ + ctx, sqlc.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, NowUtc: nowUTC, diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index da6c148ad1..8d7fc58135 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// Ensure PostgresStore satisfies the WalletStore interface. -var _ db.WalletStore = (*PostgresStore)(nil) +// Ensure Store satisfies the WalletStore interface. +var _ db.WalletStore = (*Store)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (s *PostgresStore) CreateWallet(ctx context.Context, +func (s *Store) CreateWallet(ctx context.Context, params db.CreateWalletParams) (*db.WalletInfo, error) { var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - walletParams := sqlcpg.CreateWalletParams{ + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: params.ManagerVersion, @@ -39,7 +39,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, return fmt.Errorf("create wallet: %w", err) } - secretsParams := sqlcpg.InsertWalletSecretsParams{ + secretsParams := sqlc.InsertWalletSecretsParams{ WalletID: id, MasterPrivParams: params.MasterKeyPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, @@ -63,7 +63,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, } } - syncParams := sqlcpg.InsertWalletSyncStateParams{ + syncParams := sqlc.InsertWalletSyncStateParams{ WalletID: id, SyncedHeight: sql.NullInt32{}, BirthdayHeight: sql.NullInt32{}, @@ -114,7 +114,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (s *PostgresStore) GetWallet(ctx context.Context, +func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) @@ -144,7 +144,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, } // ListWallets returns a page of wallets matching the given query. -func (s *PostgresStore) ListWallets(ctx context.Context, +func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) @@ -175,7 +175,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, } // IterWallets returns an iterator over paginated wallet results. -func (s *PostgresStore) IterWallets(ctx context.Context, +func (s *Store) IterWallets(ctx context.Context, query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( @@ -187,10 +187,10 @@ func (s *PostgresStore) IterWallets(ctx context.Context, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (s *PostgresStore) UpdateWallet(ctx context.Context, +func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists(ctx, qtx, params.SyncedTo) @@ -233,7 +233,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, +func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) @@ -256,10 +256,10 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, +func (s *Store) UpdateWalletSecrets(ctx context.Context, params db.UpdateWalletSecretsParams) error { - secretsParams := sqlcpg.UpdateWalletSecretsParams{ + secretsParams := sqlc.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, @@ -299,7 +299,7 @@ type walletRowParams struct { // listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { +func listWalletRowToInfo(row sqlc.ListWalletsRow) (*db.WalletInfo, error) { return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, @@ -319,9 +319,9 @@ func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { // listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. func listWalletsParams( - req page.Request[uint32]) sqlcpg.ListWalletsParams { + req page.Request[uint32]) sqlc.ListWalletsParams { - params := sqlcpg.ListWalletsParams{ + params := sqlc.ListWalletsParams{ PageLimit: int64(req.QueryLimit()), } @@ -387,9 +387,9 @@ func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { // buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParams(params db.UpdateWalletParams) ( - sqlcpg.UpdateWalletSyncStateParams, error) { + sqlc.UpdateWalletSyncStateParams, error) { - syncParams := sqlcpg.UpdateWalletSyncStateParams{ + syncParams := sqlc.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index bdc3cbbdf9..3a66af29e8 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresStore and sqlite.Store. It guarantees that the transaction +// pg.Store and sqlite.Store. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) From 4e66a192abc25c03964627c9b896026df36072b3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 10:41:01 +0800 Subject: [PATCH 230/695] lint: relax split backend wrapcheck Allow wrapcheck in the split pg and sqlite backend packages while the shared helper layer still sits in wallet/internal/db. This keeps lint signal elsewhere without forcing wrapper noise into the mechanical split series. --- .golangci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index fa008a01e4..b0e6afd404 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -149,6 +149,13 @@ linters: # Allow returning unwrapped errors in tests. - wrapcheck + # The split backend packages intentionally forward many calls into the + # shared db helpers. Keep wrapcheck enabled elsewhere while the package + # split settles. + - path: wallet/internal/db/(pg|sqlite)/.*\.go + linters: + - wrapcheck + - path: mock* linters: - revive From 12bedf64aebf1d3abadb3ded6fd14ef4732da7e5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:56:57 -0300 Subject: [PATCH 231/695] wallet: simplify migration instance creation --- wallet/internal/sql/pg/migrations.go | 33 ++++++++++++------------ wallet/internal/sql/sqlite/migrations.go | 33 ++++++++++++------------ 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go index a59810dcab..d046244680 100644 --- a/wallet/internal/sql/pg/migrations.go +++ b/wallet/internal/sql/pg/migrations.go @@ -7,7 +7,6 @@ import ( "fmt" gomigrate "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" migrate "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" ) @@ -15,25 +14,34 @@ import ( //go:embed migrations/*.sql var migrationFS embed.FS -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations applies all embedded postgres migrations to one database. -func applyMigrations(db *sql.DB, newDriver driverFactory) error { +// newMigrationInstance creates a migrate instance from embedded postgres +// migrations. +func newMigrationInstance(db *sql.DB) (*gomigrate.Migrate, error) { sourceDriver, err := iofs.New(migrationFS, "migrations") if err != nil { - return fmt.Errorf("create source driver: %w", err) + return nil, fmt.Errorf("create source driver: %w", err) } - driver, err := newDriver(db) + driver, err := migrate.WithInstance(db, &migrate.Config{}) if err != nil { - return fmt.Errorf("create postgres driver: %w", err) + return nil, fmt.Errorf("create postgres driver: %w", err) } m, err := gomigrate.NewWithInstance( "iofs", sourceDriver, "postgres", driver, ) if err != nil { - return fmt.Errorf("create migrate instance: %w", err) + return nil, fmt.Errorf("create migrate instance: %w", err) + } + + return m, nil +} + +// ApplyMigrations applies all PostgreSQL migrations to the database. +func ApplyMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err } err = m.Up() @@ -43,10 +51,3 @@ func applyMigrations(db *sql.DB, newDriver driverFactory) error { return nil } - -// ApplyMigrations applies all PostgreSQL migrations to the database. -func ApplyMigrations(db *sql.DB) error { - return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { - return migrate.WithInstance(db, &migrate.Config{}) - }) -} diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go index 9bea555e9c..975ca08623 100644 --- a/wallet/internal/sql/sqlite/migrations.go +++ b/wallet/internal/sql/sqlite/migrations.go @@ -7,7 +7,6 @@ import ( "fmt" gomigrate "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" migrate "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" ) @@ -15,23 +14,32 @@ import ( //go:embed migrations/*.sql var migrationFS embed.FS -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations applies all embedded sqlite migrations to one database. -func applyMigrations(db *sql.DB, newDriver driverFactory) error { +// newMigrationInstance creates a migrate instance from embedded sqlite +// migrations. +func newMigrationInstance(db *sql.DB) (*gomigrate.Migrate, error) { sourceDriver, err := iofs.New(migrationFS, "migrations") if err != nil { - return fmt.Errorf("create source driver: %w", err) + return nil, fmt.Errorf("create source driver: %w", err) } - driver, err := newDriver(db) + driver, err := migrate.WithInstance(db, &migrate.Config{}) if err != nil { - return fmt.Errorf("create sqlite driver: %w", err) + return nil, fmt.Errorf("create sqlite driver: %w", err) } m, err := gomigrate.NewWithInstance("iofs", sourceDriver, "sqlite", driver) if err != nil { - return fmt.Errorf("create migrate instance: %w", err) + return nil, fmt.Errorf("create migrate instance: %w", err) + } + + return m, nil +} + +// ApplyMigrations applies all SQLite migrations to the database. +func ApplyMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err } err = m.Up() @@ -41,10 +49,3 @@ func applyMigrations(db *sql.DB, newDriver driverFactory) error { return nil } - -// ApplyMigrations applies all SQLite migrations to the database. -func ApplyMigrations(db *sql.DB) error { - return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { - return migrate.WithInstance(db, &migrate.Config{}) - }) -} From 9bf5e56eb45f4eaacce0eaa5b4c57d5d4b6471bd Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:57:27 -0300 Subject: [PATCH 232/695] wallet: add rollback migrations functionality --- wallet/internal/sql/pg/migrations.go | 15 +++++++++++++++ wallet/internal/sql/sqlite/migrations.go | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go index d046244680..756e18b125 100644 --- a/wallet/internal/sql/pg/migrations.go +++ b/wallet/internal/sql/pg/migrations.go @@ -51,3 +51,18 @@ func ApplyMigrations(db *sql.DB) error { return nil } + +// RollbackMigrations rolls back all PostgreSQL migrations from the database. +func RollbackMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err + } + + err = m.Down() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("rollback migrations: %w", err) + } + + return nil +} diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go index 975ca08623..05e70e63e0 100644 --- a/wallet/internal/sql/sqlite/migrations.go +++ b/wallet/internal/sql/sqlite/migrations.go @@ -49,3 +49,18 @@ func ApplyMigrations(db *sql.DB) error { return nil } + +// RollbackMigrations rolls back all SQLite migrations from the database. +func RollbackMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err + } + + err = m.Down() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("rollback migrations: %w", err) + } + + return nil +} From 1b7aae85aa3d1cbc55cbcf695073e93bc807bc7c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 12 Apr 2026 14:00:40 -0300 Subject: [PATCH 233/695] wallet: fix addresses rollback objects --- wallet/internal/sql/pg/migrations/000006_addresses.down.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.down.sql b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql index ea5ce7ef37..389c49d649 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.down.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql @@ -1,5 +1,9 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TRIGGER IF EXISTS trg_addresses_imported_key_count_insert ON addresses; +DROP TRIGGER IF EXISTS trg_addresses_imported_key_count_delete ON addresses; +DROP FUNCTION IF EXISTS sync_account_imported_key_count_insert(); +DROP FUNCTION IF EXISTS sync_account_imported_key_count_delete(); DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; From 8a683e1fa2b4272419b6b112c7c331320b3af4a8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:56:02 -0300 Subject: [PATCH 234/695] wallet: test migration rollback reapply --- .../db/itest/migration_rollback_test.go | 25 +++++++++++++++++++ wallet/internal/db/pg/itest.go | 11 ++++++++ wallet/internal/db/sqlite/itest.go | 11 ++++++++ 3 files changed, 47 insertions(+) create mode 100644 wallet/internal/db/itest/migration_rollback_test.go diff --git a/wallet/internal/db/itest/migration_rollback_test.go b/wallet/internal/db/itest/migration_rollback_test.go new file mode 100644 index 0000000000..69c24a9030 --- /dev/null +++ b/wallet/internal/db/itest/migration_rollback_test.go @@ -0,0 +1,25 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMigrationsRollbackReapply ensures that the full migration chain +// can be cleanly rolled back and then reapplied without errors. +func TestMigrationsRollbackReapply(t *testing.T) { + t.Parallel() + + s := NewTestStore(t) + + err := s.RollbackAllMigrations() + require.NoError(t, err, "failed to rollback all migrations") + + // Reapply all migrations to verify that the database can return to a + // valid, fully migrated state after a complete rollback. + err = s.ApplyAllMigrations() + require.NoError(t, err, "failed to reapply all migrations") +} diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 2e66940b9b..0a4f62b514 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -5,6 +5,7 @@ package pg import ( "database/sql" + sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) @@ -17,3 +18,13 @@ func (s *Store) DB() *sql.DB { func (s *Store) Queries() *sqlc.Queries { return s.queries } + +// RollbackAllMigrations rolls back all PostgreSQL migrations. +func (s *Store) RollbackAllMigrations() error { + return sqlassetpg.RollbackMigrations(s.db) +} + +// ApplyAllMigrations reapplies all PostgreSQL migrations. +func (s *Store) ApplyAllMigrations() error { + return sqlassetpg.ApplyMigrations(s.db) +} diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 9069ae7af7..623078d13f 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,6 +5,7 @@ package sqlite import ( "database/sql" + sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) @@ -17,3 +18,13 @@ func (s *Store) DB() *sql.DB { func (s *Store) Queries() *sqlc.Queries { return s.queries } + +// RollbackAllMigrations rolls back all SQLite migrations. +func (s *Store) RollbackAllMigrations() error { + return sqlassetsqlite.RollbackMigrations(s.db) +} + +// ApplyAllMigrations reapplies all SQLite migrations. +func (s *Store) ApplyAllMigrations() error { + return sqlassetsqlite.ApplyMigrations(s.db) +} From 060eeb22c081d1344d12f97127fffecd8d5eb6d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 03:32:02 +0800 Subject: [PATCH 235/695] wallet: simplify pagination api Require callers to pass explicit, non-zero page limits and remove shared default and max-limit policy from the page package. This keeps page focused on request/result semantics while stores own query sizing and validation. Use pointer-style After and Next tokens so manual pagination can forward the next page token directly without HasAfter or HasNext plumbing. Keep result assembly in page, update wallet and address stores to fetch limit+1 rows explicitly, and refresh tests and docs to match the simpler API. --- wallet/internal/db/addresses_common.go | 2 +- wallet/internal/db/interface.go | 4 + .../internal/db/itest/address_store_test.go | 72 ++++--- wallet/internal/db/itest/wallet_store_test.go | 67 ++++-- wallet/internal/db/page/doc.go | 20 +- wallet/internal/db/page/iter_test.go | 97 +++------ wallet/internal/db/page/request.go | 106 ++-------- wallet/internal/db/page/request_test.go | 195 +----------------- wallet/internal/db/page/result.go | 4 +- wallet/internal/db/pg/addresses.go | 11 +- wallet/internal/db/pg/wallet.go | 12 +- wallet/internal/db/sqlite/addresses.go | 11 +- wallet/internal/db/sqlite/wallet.go | 12 +- wallet/internal/db/wallets_common.go | 2 +- 14 files changed, 195 insertions(+), 420 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 74238117b6..cec7c61ec3 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -364,7 +364,7 @@ func GetAddress[T any, Args any](ctx context.Context, func NextListAddressesQuery(q ListAddressesQuery, cursor uint32) ListAddressesQuery { - q.Page = q.Page.WithAfter(cursor) + q.Page.After = &cursor return q } diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 4be4dfd46d..4f61052883 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -50,6 +50,10 @@ var ( // field combinations. ErrInvalidAddressQuery = errors.New("ScriptPubKey must be provided") + // ErrInvalidPageLimit is returned when a paginated query is called with a + // zero page limit. + ErrInvalidPageLimit = errors.New("page limit must be greater than zero") + // ErrMissingScriptPubKey is returned when creating an imported // address without the required script public key. ErrMissingScriptPubKey = errors.New("script pubkey required") diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 80f7f42153..08d5fdb8d7 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -85,9 +85,8 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, return account } -// collectAddressPages collects paginated address results by iterating -// through all pages from ListAddresses, using cursor pagination until -// Next is nil. +// collectAddressPages collects paginated address results by iterating through +// all pages from ListAddresses until Next is nil. func collectAddressPages(t *testing.T, store db.AddressStore, query db.ListAddressesQuery) []page.Result[db.AddressInfo, uint32] { t.Helper() @@ -102,7 +101,7 @@ func collectAddressPages(t *testing.T, store db.AddressStore, return pages } - query.Page = query.Page.WithAfter(*pageResult.Next) + query.Page.After = pageResult.Next } } @@ -739,6 +738,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "test-account", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 5, @@ -766,6 +766,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "empty-account", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 0, @@ -803,6 +804,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "bip44-multi", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 3, @@ -841,6 +843,21 @@ func TestListAddresses(t *testing.T) { } } +// TestListAddressesZeroLimit verifies ListAddresses rejects a zero page limit. +func TestListAddressesZeroLimit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-zero-limit") + + _, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "test-account", + }) + require.ErrorIs(t, err, db.ErrInvalidPageLimit) +} + // TestNewDerivedAddress verifies that NewDerivedAddress correctly creates // derived addresses with proper AddressInfo fields for both external and // change addresses. @@ -996,6 +1013,7 @@ func TestListAddressesOrdering(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "ordering-account", + Page: page.Request[uint32]{Limit: 10}, }, ) @@ -1053,7 +1071,7 @@ func TestListAddressesPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) @@ -1062,21 +1080,21 @@ func TestListAddressesPagination(t *testing.T) { require.Equal(t, accountA[:2], page1.Items) require.NotNil(t, page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) require.Equal(t, accountA[2:4], page2.Items) require.NotNil(t, page2.Next) - query.Page = query.Page.WithAfter(*page2.Next) + query.Page.After = page2.Next page3, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page3.Items, 1) require.Equal(t, accountA[4:], page3.Items) require.Nil(t, page3.Next) - query.Page = query.Page.WithAfter(page3.Items[len(page3.Items)-1].ID) + query.Page.After = uint32Ptr(page3.Items[len(page3.Items)-1].ID) page4, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Empty(t, page4.Items) @@ -1111,7 +1129,7 @@ func TestListAddressesExactBoundary(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) @@ -1120,14 +1138,14 @@ func TestListAddressesExactBoundary(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Equal(t, expected[2:], page2.Items) require.Nil(t, page2.Next) require.Greater(t, page2.Items[0].ID, *page1.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -1150,7 +1168,7 @@ func TestListAddressesPagedEmptyResult(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + Page: page.Request[uint32]{Limit: uint32(pageSize)}, }) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -1175,7 +1193,7 @@ func TestListAddressesDeterministicPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -1247,7 +1265,7 @@ func TestListAddressesAccountIsolation(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) addresses := flattenAddressPages(pages) @@ -1277,8 +1295,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) @@ -1291,7 +1308,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { t, store, walletID, scope, accountName, false, ) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -1317,9 +1334,10 @@ func TestListAddressesCursorEdges(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2). - WithAfter(math.MaxUint32), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(math.MaxUint32), + }, }) require.NoError(t, err) require.Empty(t, stalePage.Items) @@ -1329,9 +1347,10 @@ func TestListAddressesCursorEdges(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2). - WithAfter(0), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(0), + }, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -1359,7 +1378,7 @@ func TestIterAddresses(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + Page: page.Request[uint32]{Limit: uint32(pageSize)}, } iterAddrs := make([]db.AddressInfo, 0, len(expected)) @@ -1392,8 +1411,7 @@ func TestIterAddressesPaginated(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{}. - WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } pages := collectAddressPages(t, store, query) @@ -1427,7 +1445,7 @@ func TestIterAddressesEmpty(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{}.WithLimit(10), + Page: page.Request[uint32]{Limit: 10}, } for addr, err := range store.IterAddresses(t.Context(), query) { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index ab7d0eee95..a4c3d416f8 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -12,6 +12,11 @@ import ( "github.com/stretchr/testify/require" ) +// uint32Ptr returns a pointer to the given uint32 value. +func uint32Ptr(v uint32) *uint32 { + return &v +} + // TestCreateWallet verifies that CreateWallet correctly creates a wallet // and returns its information. func TestCreateWallet(t *testing.T) { @@ -156,7 +161,7 @@ func TestListWallets(t *testing.T) { // Initially empty. query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(10), + Page: page.Request[uint32]{Limit: 10}, } pageResult, err := store.ListWallets(t.Context(), query) @@ -184,6 +189,16 @@ func TestListWallets(t *testing.T) { require.ElementsMatch(t, names, walletsName) } +// TestListWalletsZeroLimit verifies ListWallets rejects a zero page limit. +func TestListWalletsZeroLimit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + _, err := store.ListWallets(t.Context(), db.ListWalletsQuery{}) + require.ErrorIs(t, err, db.ErrInvalidPageLimit) +} + // TestListWalletsPagination verifies that ListWallets paginates correctly and // sets Next without requiring an extra round-trip. func TestListWalletsPagination(t *testing.T) { @@ -199,7 +214,7 @@ func TestListWalletsPagination(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) @@ -208,13 +223,13 @@ func TestListWalletsPagination(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) require.Nil(t, page2.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -246,7 +261,7 @@ func TestListWalletsExactBoundary(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) @@ -257,7 +272,7 @@ func TestListWalletsExactBoundary(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -266,7 +281,7 @@ func TestListWalletsExactBoundary(t *testing.T) { require.Nil(t, page2.Next) require.Greater(t, page2.Items[0].ID, *page1.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -289,7 +304,7 @@ func TestIterWallets(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } expected := flattenWalletPages(collectWalletPages(t, store, query)) @@ -317,7 +332,7 @@ func TestIterWalletsPaginated(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } pages := collectWalletPages(t, store, query) @@ -354,7 +369,10 @@ func TestListWalletsPagedFromCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(created[1].ID), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(created[1].ID), + }, } pageResult, err := store.ListWallets(t.Context(), query) @@ -364,7 +382,7 @@ func TestListWalletsPagedFromCursor(t *testing.T) { require.Equal(t, names[3], pageResult.Items[1].Name) require.Nil(t, pageResult.Next) - query.Page = query.Page.WithAfter(created[3].ID) + query.Page.After = uint32Ptr(created[3].ID) pageResult, err = store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -410,7 +428,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.NoError(t, err) query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(1), + Page: page.Request[uint32]{Limit: 1}, } page1, err := store.ListWallets(t.Context(), query) @@ -421,7 +439,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.False(t, page1.Items[0].Birthday.IsZero()) require.NotNil(t, page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 1) @@ -453,7 +471,7 @@ func TestListWalletsDeterministicPagination(t *testing.T) { } pages := collectWalletPages(t, store, db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -521,7 +539,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) require.NoError(t, err) @@ -535,7 +553,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { ) require.NoError(t, err) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -560,14 +578,20 @@ func TestListWalletsCursorEdges(t *testing.T) { } stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(math.MaxUint32), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(math.MaxUint32), + }, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(0), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(0), + }, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -576,9 +600,8 @@ func TestListWalletsCursorEdges(t *testing.T) { require.NotNil(t, zeroPage.Next) } -// collectWalletPages collects paginated wallet results by iterating -// through all pages from ListWallets, using cursor pagination until -// Next is nil. +// collectWalletPages collects paginated wallet results by iterating through all +// pages from ListWallets until Next is nil. func collectWalletPages(t *testing.T, store db.WalletStore, query db.ListWalletsQuery) []page.Result[db.WalletInfo, uint32] { t.Helper() @@ -593,7 +616,7 @@ func collectWalletPages(t *testing.T, store db.WalletStore, return pages } - query.Page = query.Page.WithAfter(*pageResult.Next) + query.Page.After = pageResult.Next } } diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go index b0795cd0f0..b864b87a9b 100644 --- a/wallet/internal/db/page/doc.go +++ b/wallet/internal/db/page/doc.go @@ -5,15 +5,15 @@ // // A [Request] carries the parameters for a single page fetch: page limit, // and an optional after that identifies where the previous page ended. -// The zero value requests the first page at [DefaultLimit]. // // A [Result] carries the items returned by one fetch together with -// [Result.Next]. Pass *Next back to [Request.WithAfter] to advance to the -// next page. +// [Result.Next]. Assign [Result.Next] to [Request.After] to advance to the next +// page. // -// Queries fetch normalizedLimit+1 rows internally and return at most -// normalizedLimit items. If the extra row exists, [Result.Next] is non-nil. -// If it does not, [Result.Next] is nil and the current page is the last page. +// Stores require [Request.Limit] to be positive, fetch one extra row +// internally, and return at most the requested number of items. If the extra +// row exists, [Result.Next] is non-nil. If it does not, [Result.Next] is nil +// and the current page is the last page. // // # Iterating // @@ -24,8 +24,8 @@ // // # Store integration // -// Stores typically translate [Request.After] into an optional backend -// query parameter, fetch [Request.QueryLimit] rows with a single ordered -// SQL query, map the raw rows to domain items, and then call -// [BuildResult] to derive [Result.Next]. +// Stores typically translate [Request.After] into an optional backend query +// parameter, validate [Request.Limit], fetch `limit+1` rows with a single +// ordered SQL query, and call [BuildResult] to derive [Result.Next] from the +// last returned item when another page exists. package page diff --git a/wallet/internal/db/page/iter_test.go b/wallet/internal/db/page/iter_test.go index 3b574bfbd9..11c1ba8eff 100644 --- a/wallet/internal/db/page/iter_test.go +++ b/wallet/internal/db/page/iter_test.go @@ -8,17 +8,16 @@ import ( "github.com/stretchr/testify/require" ) -// errTest is a sentinel error used across page package tests to -// verify error propagation through pagination helpers. +// errTest is a sentinel error used across page package tests. var errTest = errors.New("test error") // intPtr returns a pointer to the given int value. It is used in tests to -// construct Result literals that require a *int after. +// construct Result literals that require a *int cursor. func intPtr(v int) *int { return &v } -// TestIterTraversal tests the traversal of items using the page iterator. +// TestIterTraversal verifies that Iter walks all pages in order. func TestIterTraversal(t *testing.T) { t.Parallel() @@ -83,8 +82,8 @@ func TestIterTraversal(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, query int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + query int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) require.Equal(t, fetchCalls, query) @@ -111,9 +110,7 @@ func TestIterTraversal(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) @@ -128,13 +125,14 @@ func TestIterTraversal(t *testing.T) { } // TestIterFetchErrorOnFirstCall verifies that Iter yields the error immediately -// when fetchPage fails on the very first call, before any items are produced. +// when fetchPage fails on the first call, before any items are produced. func TestIterFetchErrorOnFirstCall(t *testing.T) { t.Parallel() - gotItems := make([]int, 0) - - var iterErr error + var ( + gotItems = make([]int, 0) + iterErr error + ) fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { return Result[int, int]{}, errTest @@ -144,9 +142,7 @@ func TestIterFetchErrorOnFirstCall(t *testing.T) { return 0 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -168,14 +164,10 @@ func TestIterFetchErrorAfterTwoPages(t *testing.T) { fetchCalls int nextCursors []int iterErr error + gotItems = make([]int, 0, 4) ) - gotItems := make([]int, 0) - - fetchPage := func( - _ context.Context, _ int, - ) (Result[int, int], error) { - + fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { fetchCalls++ switch fetchCalls { case 1: @@ -198,9 +190,7 @@ func TestIterFetchErrorAfterTwoPages(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -242,11 +232,8 @@ func TestIterContextCancellation(t *testing.T) { wantFetchCalls: 1, }, { - name: "cancel mid-page stops before next yield", - pages: [][]int{ - {1, 2, 3}, - {4, 5}, - }, + name: "cancel mid-page stops before next yield", + pages: [][]int{{1, 2, 3}, {4, 5}}, cancelAfterItems: 1, wantItems: []int{1}, wantFetchCalls: 1, @@ -270,25 +257,21 @@ func TestIterContextCancellation(t *testing.T) { cancel() } - fetchPage := func(ctx context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(ctx context.Context, + _ int) (Result[int, int], error) { err := ctx.Err() if err != nil { fetchCalls++ - return Result[int, int]{}, err } require.Less(t, fetchCalls, len(tc.pages)) - items := tc.pages[fetchCalls] fetchCalls++ - result := Result[int, int]{ - Items: items, - } - if len(items) > 0 { + result := Result[int, int]{Items: items} + if fetchCalls < len(tc.pages) && len(items) > 0 { last := items[len(items)-1] result.Next = &last } @@ -300,9 +283,7 @@ func TestIterContextCancellation(t *testing.T) { return query + 1 } - for item, err := range Iter( - ctx, 0, fetchPage, setCursor, - ) { + for item, err := range Iter(ctx, 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -323,8 +304,8 @@ func TestIterContextCancellation(t *testing.T) { } } -// TestIterConsumerBreaks tests the page iterator when the consumer breaks out -// of the loop. +// TestIterConsumerBreaks verifies that Iter stops without fetching another page +// when the consumer breaks early. func TestIterConsumerBreaks(t *testing.T) { t.Parallel() @@ -345,9 +326,6 @@ func TestIterConsumerBreaks(t *testing.T) { wantNextCalls: 0, }, { - // The consumer stops after consuming all items in the first page. - // setCursor is never called because the break happens before the - // iterator advances to the next page. name: "at page boundary", pages: [][]int{{1, 2}, {3, 4}}, stopAfter: 2, @@ -367,8 +345,8 @@ func TestIterConsumerBreaks(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + _ int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) @@ -376,9 +354,7 @@ func TestIterConsumerBreaks(t *testing.T) { hasMore := fetchCalls < len(tc.pages)-1 fetchCalls++ - result := Result[int, int]{ - Items: items, - } + result := Result[int, int]{Items: items} if hasMore && len(items) > 0 { last := items[len(items)-1] result.Next = &last @@ -389,13 +365,10 @@ func TestIterConsumerBreaks(t *testing.T) { setCursor := func(query int, cursor int) int { nextCalls++ - return query + cursor } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) @@ -411,8 +384,8 @@ func TestIterConsumerBreaks(t *testing.T) { } } -// TestIterNextNilTermination verifies Iter stops when Next becomes nil, -// without requiring an extra empty-page fetch. +// TestIterNextNilTermination verifies Iter stops when Next becomes nil without +// requiring an extra empty-page fetch. func TestIterNextNilTermination(t *testing.T) { t.Parallel() @@ -433,7 +406,7 @@ func TestIterNextNilTermination(t *testing.T) { wantNextCalls: 0, }, { - name: "multi-page, stops mid", + name: "multi-page stops at nil next", pages: []Result[int, int]{ {Items: []int{1, 2}, Next: intPtr(2)}, {Items: []int{3}}, @@ -454,8 +427,8 @@ func TestIterNextNilTermination(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + _ int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) @@ -471,9 +444,7 @@ func TestIterNextNilTermination(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go index 8653199fab..6adcd4759b 100644 --- a/wallet/internal/db/page/request.go +++ b/wallet/internal/db/page/request.go @@ -1,95 +1,31 @@ package page -const ( - // DefaultLimit is the default number of items that can be returned to a - // page. - DefaultLimit = 100 - - // MaxLimit is the maximum number of items that can be returned to a page. - // Store implementations may fetch MaxLimit+1 rows internally to detect - // whether another page exists. - MaxLimit = 1000 -) - -// Request holds the parameters for a paginated list query. The zero value is -// valid and requests the first page at DefaultLimit. All With* methods -// return a modified shallow copy of the request. +// Request holds the parameters for a paginated list query. // -// Fields are unexported so that callers must use the With* methods and -// accessor functions. This design preserves the normalization of limit (zero -// maps to DefaultLimit via normalizedLimit()) and ensures consistency across -// all page operations. +// Limit must be greater than zero. type Request[Cursor any] struct { - // limit is the maximum number of items to return per page. - limit uint32 - - // after is the pagination cursor that marks where the next page - // starts after. Nil means the first page. - after *Cursor -} - -// QueryLimit returns the number of rows the SQL query should fetch. Queries -// fetch normalizedLimit+1 rows, so BuildResult can use the extra row as a -// lookahead signal to determine whether another page exists. -func (r Request[Cursor]) QueryLimit() uint32 { - return r.normalizedLimit() + 1 -} - -// WithLimit returns a copy of the request with the limit replaced. The limit is -// not validated here; normalization (zero -> DefaultLimit, over MaxLimit -> -// MaxLimit) happens in normalizedLimit() and QueryLimit(). A caller passing -// 0 or a large value will not see an error, the value will just be normalized -// later. -func (r Request[Cursor]) WithLimit(limit uint32) Request[Cursor] { - r.limit = limit - - return r -} - -// After returns the pagination after from the previous page. -// A false ok return value means the first page is being requested. -func (r Request[Cursor]) After() (Cursor, bool) { - if r.after == nil { - var zero Cursor - - return zero, false - } - - return *r.after, true -} + // Limit is the maximum number of items to return in one page. + Limit uint32 -// WithAfter returns a copy of the request with the after replaced. -// Calling this on a zero-value Request produces a request for the second page -// (the page after this after). It takes the after by value to avoid -// the caller retaining a pointer into the Request. -func (r Request[Cursor]) WithAfter(after Cursor) Request[Cursor] { - r.after = &after - - return r + // After is the cursor identifying where the next page starts after. Nil + // means the request targets the first page. + After *Cursor } -// BuildResult assembles a page.Result from a slice of items already fetched by -// the caller. It uses r.normalizedLimit to determine whether the query fetched -// an extra lookahead row. The toCursor function is called on the last item of -// the possibly trimmed slice. -// -// An empty slice always returns an empty result. +// BuildResult assembles a page result from a slice of items already fetched by +// the caller. // -// If len(items) is greater than normalizedLimit, it trims to normalizedLimit -// and sets Next to the after of the last item in the trimmed slice. -// Otherwise, Next is nil. -func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, +// The caller must pass a positive limit. BuildResult trims an extra lookahead +// row when present and derives Next from the last retained item. +func BuildResult[Cursor, Item any](items []Item, limit uint32, nextOf func(Item) Cursor) Result[Item, Cursor] { if len(items) == 0 { return Result[Item, Cursor]{Items: items} } - limit := r.normalizedLimit() if len(items) <= int(limit) { - return Result[Item, Cursor]{ - Items: items, - } + return Result[Item, Cursor]{Items: items} } items = items[:int(limit)] @@ -101,19 +37,3 @@ func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, Next: &cursor, } } - -// normalizedLimit returns the normalized requested page limit for this request. -// A limit of zero returns DefaultLimit. A limit greater than MaxLimit is -// clamped to MaxLimit. -func (r Request[Cursor]) normalizedLimit() uint32 { - switch { - case r.limit == 0: - return DefaultLimit - - case r.limit > MaxLimit: - return MaxLimit - - default: - return r.limit - } -} diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go index e1de74ca5a..c64ab4e95f 100644 --- a/wallet/internal/db/page/request_test.go +++ b/wallet/internal/db/page/request_test.go @@ -6,183 +6,9 @@ import ( "github.com/stretchr/testify/require" ) -// TestRequestSize verifies that normalizedLimit normalizes the raw limit field. -func TestRequestSize(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - size uint32 - wantSize uint32 - }{ - { - name: "zero defaults to DefaultLimit", - size: 0, - wantSize: DefaultLimit, - }, - { - name: "minimum in range", - size: 1, - wantSize: 1, - }, - { - name: "exactly MaxLimit passes through", - size: MaxLimit, - wantSize: MaxLimit, - }, - { - name: "over MaxLimit clamped to MaxLimit", - size: uint32(MaxLimit) + 1, - wantSize: MaxLimit, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - request := Request[uint32]{}.WithLimit(tc.size) - require.Equal(t, tc.wantSize, request.normalizedLimit()) - }) - } -} - -// TestQueryLimit verifies that QueryLimit returns normalizedLimit+1, -// including at MaxLimit. -func TestQueryLimit(t *testing.T) { - t.Parallel() - - t.Run("uses limit plus one", func(t *testing.T) { - t.Parallel() - - r := Request[uint32]{}.WithLimit(25) - require.Equal(t, r.normalizedLimit()+1, r.QueryLimit()) - }) - - t.Run("at max page limit", func(t *testing.T) { - t.Parallel() - - r := Request[uint32]{}.WithLimit(MaxLimit) - require.Equal(t, uint32(MaxLimit)+1, r.QueryLimit()) - }) -} - -// TestRequestChaining verifies that chaining With* calls produces the -// expected limit and after on the resulting Request. -func TestRequestChaining(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - request Request[uint32] - wantSize uint32 - wantCursor uint32 - wantHasCursor bool - }{ - { - name: "after nil by default", - request: Request[uint32]{}, - wantSize: DefaultLimit, - wantHasCursor: false, - }, - { - name: "after set without limit", - request: Request[uint32]{}.WithAfter(42), - wantSize: DefaultLimit, - wantCursor: 42, - wantHasCursor: true, - }, - { - name: "limit and after set together", - request: Request[uint32]{}.WithLimit(50).WithAfter(99), - wantSize: 50, - wantCursor: 99, - wantHasCursor: true, - }, - { - name: "after overwrites previous after", - request: Request[uint32]{}.WithAfter(1).WithAfter(2), - wantSize: DefaultLimit, - wantCursor: 2, - wantHasCursor: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - require.Equal(t, tc.wantSize, tc.request.normalizedLimit()) - - if !tc.wantHasCursor { - _, ok := tc.request.After() - require.False(t, ok) - - return - } - - after, ok := tc.request.After() - require.True(t, ok) - require.Equal(t, tc.wantCursor, after) - }) - } -} - -// TestRequestWithSizeImmutability verifies that WithLimit returns a new -// Request and does not modify the original. -func TestRequestWithSizeImmutability(t *testing.T) { - t.Parallel() - - original := Request[uint32]{}.WithLimit(10).WithAfter(7) - updated := original.WithLimit(20) - - require.Equal(t, uint32(10), original.normalizedLimit()) - originalAfter, ok := original.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) - - require.Equal(t, uint32(20), updated.normalizedLimit()) - updatedAfter, ok := updated.After() - require.True(t, ok) - require.Equal(t, uint32(7), updatedAfter) -} - -// TestRequestWithCursorImmutability verifies that WithAfter returns a new -// Request and does not modify the original. -func TestRequestWithCursorImmutability(t *testing.T) { - t.Parallel() - - original := Request[uint32]{}.WithLimit(10).WithAfter(7) - updated := original.WithAfter(9) - - require.Equal(t, uint32(10), original.normalizedLimit()) - originalAfter, ok := original.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) - - require.Equal(t, uint32(10), updated.normalizedLimit()) - updatedAfter, ok := updated.After() - require.True(t, ok) - require.Equal(t, uint32(9), updatedAfter) -} - -// TestRequestAfterReturnsCursorCopy verifies that mutating the local cursor -// variable returned by After does not mutate the request's internal cursor. -func TestRequestAfterReturnsCursorCopy(t *testing.T) { - t.Parallel() - - request := Request[uint32]{}.WithAfter(7) - after, ok := request.After() - - require.True(t, ok) - require.Equal(t, uint32(7), after) - - after = 9 - require.Equal(t, uint32(9), after) - - originalAfter, ok := request.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) +// intPtrRequest returns a pointer to the given int value. +func intPtrRequest(v int) *int { + return &v } // TestBuildResult verifies BuildResult assembles the correct Result using the @@ -195,37 +21,37 @@ func TestBuildResult(t *testing.T) { testCases := []struct { name string items []int - size uint32 + limit uint32 wantItems []int wantNext *int }{ { name: "empty slice returns empty result", items: []int{}, - size: 100, + limit: 100, wantItems: []int{}, wantNext: nil, }, { name: "len less than limit leaves Next nil", items: []int{1, 2}, - size: 5, + limit: 5, wantItems: []int{1, 2}, wantNext: nil, }, { name: "len equal to limit leaves Next nil", items: []int{1, 2}, - size: 2, + limit: 2, wantItems: []int{1, 2}, wantNext: nil, }, { name: "len greater than limit trims and sets Next", items: []int{1, 2, 3}, - size: 2, + limit: 2, wantItems: []int{1, 2}, - wantNext: func() *int { v := 2; return &v }(), + wantNext: intPtrRequest(2), }, } @@ -233,8 +59,7 @@ func TestBuildResult(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - req := Request[int]{}.WithLimit(tc.size) - result := BuildResult(req, tc.items, toCursor) + result := BuildResult(tc.items, tc.limit, toCursor) require.Equal(t, tc.wantItems, result.Items) require.Equal(t, tc.wantNext, result.Next) diff --git a/wallet/internal/db/page/result.go b/wallet/internal/db/page/result.go index 4387aa882b..b7aadf9ccd 100644 --- a/wallet/internal/db/page/result.go +++ b/wallet/internal/db/page/result.go @@ -6,7 +6,7 @@ type Result[T any, C any] struct { // page. Items []T - // Next is the after to use for fetching the next page. It is nil when - // no more pages exist. + // Next is the after to use for fetching the next page. It is nil when no + // more pages exist. Next *C } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index b21b9fe96c..742029f934 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -37,6 +37,9 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit + } items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { @@ -44,7 +47,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.AddressInfo) uint32 { return item.ID }, @@ -355,12 +358,12 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.QueryLimit()), + PageLimit: int64(q.Page.Limit) + 1, } - if cursor, ok := q.Page.After(); ok { + if q.Page.After != nil { params.CursorID = sql.NullInt64{ - Int64: int64(cursor), + Int64: int64(*q.Page.After), Valid: true, } } diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 8d7fc58135..1e29566761 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -147,6 +147,10 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit + } + rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) if err != nil { return page.Result[db.WalletInfo, uint32]{}, @@ -165,7 +169,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.WalletInfo) uint32 { return item.ID }, @@ -322,12 +326,12 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.QueryLimit()), + PageLimit: int64(req.Limit) + 1, } - if cursor, ok := req.After(); ok { + if req.After != nil { params.CursorID = sql.NullInt64{ - Int64: int64(cursor), + Int64: int64(*req.After), Valid: true, } } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 1dea5eeaec..0bd647be7c 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -37,6 +37,9 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit + } items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { @@ -44,7 +47,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.AddressInfo) uint32 { return item.ID }, @@ -351,11 +354,11 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.QueryLimit()), + PageLimit: int64(q.Page.Limit) + 1, } - if cursor, ok := q.Page.After(); ok { - params.CursorID = int64(cursor) + if q.Page.After != nil { + params.CursorID = int64(*q.Page.After) } return params diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index eaf0f9fd85..16a2bbc0a4 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -147,6 +147,10 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit + } + rows, err := s.queries.ListWallets( ctx, listWalletsParams(query.Page), ) @@ -167,7 +171,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.WalletInfo) uint32 { return item.ID }, @@ -325,11 +329,11 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.QueryLimit()), + PageLimit: int64(req.Limit) + 1, } - if cursor, ok := req.After(); ok { - params.CursorID = int64(cursor) + if req.After != nil { + params.CursorID = int64(*req.After) } return params diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go index cfeb7b5f3d..64f610f769 100644 --- a/wallet/internal/db/wallets_common.go +++ b/wallet/internal/db/wallets_common.go @@ -3,7 +3,7 @@ package db // NextListWalletsQuery returns a query with its pagination cursor advanced to // the provided value. func NextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { - q.Page = q.Page.WithAfter(cursor) + q.Page.After = &cursor return q } From e16140ab16110d3b788928bc28f6ffb68c46b620 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 13 Apr 2026 17:48:03 +0800 Subject: [PATCH 236/695] wallet: validate page limits on construction Hide the page limit behind Request construction so callers cannot set it to zero with struct literals while still allowing After to remain a plain continuation token. This keeps page-by-page usage direct while pushing limit validation closer to request creation instead of relying solely on store entry points. Use page.NewRequest in pagination tests and route store code through Request.Limit so SQL builders and result assembly continue to use the same positive limit contract. Keep the store-side zero-limit checks as defensive validation for zero-valued requests. --- wallet/internal/db/interface.go | 2 +- .../internal/db/itest/address_store_test.go | 42 ++++++++-------- wallet/internal/db/itest/wallet_store_test.go | 50 +++++++++++-------- wallet/internal/db/page/doc.go | 3 +- wallet/internal/db/page/request.go | 35 ++++++++++--- wallet/internal/db/page/request_test.go | 43 +++++++++++++++- wallet/internal/db/pg/addresses.go | 7 +-- wallet/internal/db/pg/wallet.go | 6 +-- wallet/internal/db/sqlite/addresses.go | 7 +-- wallet/internal/db/sqlite/wallet.go | 6 +-- 10 files changed, 137 insertions(+), 64 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 4f61052883..49f531beec 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -52,7 +52,7 @@ var ( // ErrInvalidPageLimit is returned when a paginated query is called with a // zero page limit. - ErrInvalidPageLimit = errors.New("page limit must be greater than zero") + ErrInvalidPageLimit = page.ErrInvalidLimit // ErrMissingScriptPubKey is returned when creating an imported // address without the required script public key. diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 08d5fdb8d7..e42dbfa842 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -738,7 +738,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "test-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 5, @@ -766,7 +766,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 0, @@ -804,7 +804,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "bip44-multi", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 3, @@ -1013,7 +1013,7 @@ func TestListAddressesOrdering(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "ordering-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), }, ) @@ -1071,7 +1071,7 @@ func TestListAddressesPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) @@ -1129,7 +1129,7 @@ func TestListAddressesExactBoundary(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) @@ -1168,7 +1168,7 @@ func TestListAddressesPagedEmptyResult(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: uint32(pageSize)}, + Page: newTestReq[uint32](t, uint32(pageSize)), }) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -1193,7 +1193,7 @@ func TestListAddressesDeterministicPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -1265,7 +1265,7 @@ func TestListAddressesAccountIsolation(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) addresses := flattenAddressPages(pages) @@ -1295,7 +1295,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) @@ -1330,27 +1330,27 @@ func TestListAddressesCursorEdges(t *testing.T) { createDerivedAccount(t, store, walletID, scope, accountName) createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + staleReq := newTestReq[uint32](t, 2) + staleReq.After = uint32Ptr(math.MaxUint32) + stalePage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(math.MaxUint32), - }, + Page: staleReq, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) + zeroReq := newTestReq[uint32](t, 2) + zeroReq.After = uint32Ptr(0) + zeroPage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(0), - }, + Page: zeroReq, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -1378,7 +1378,7 @@ func TestIterAddresses(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{Limit: uint32(pageSize)}, + Page: newTestReq[uint32](t, uint32(pageSize)), } iterAddrs := make([]db.AddressInfo, 0, len(expected)) @@ -1411,7 +1411,7 @@ func TestIterAddressesPaginated(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } pages := collectAddressPages(t, store, query) @@ -1445,7 +1445,7 @@ func TestIterAddressesEmpty(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } for addr, err := range store.IterAddresses(t.Context(), query) { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index a4c3d416f8..b9150e23c4 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -17,6 +17,16 @@ func uint32Ptr(v uint32) *uint32 { return &v } +// newTestReq constructs a valid pagination request for tests. +func newTestReq[C any](t *testing.T, limit uint32) page.Request[C] { + t.Helper() + + req, err := page.NewRequest[C](limit) + require.NoError(t, err) + + return req +} + // TestCreateWallet verifies that CreateWallet correctly creates a wallet // and returns its information. func TestCreateWallet(t *testing.T) { @@ -161,7 +171,7 @@ func TestListWallets(t *testing.T) { // Initially empty. query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } pageResult, err := store.ListWallets(t.Context(), query) @@ -214,7 +224,7 @@ func TestListWalletsPagination(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) @@ -261,7 +271,7 @@ func TestListWalletsExactBoundary(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) @@ -304,7 +314,7 @@ func TestIterWallets(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } expected := flattenWalletPages(collectWalletPages(t, store, query)) @@ -332,7 +342,7 @@ func TestIterWalletsPaginated(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } pages := collectWalletPages(t, store, query) @@ -368,11 +378,11 @@ func TestListWalletsPagedFromCursor(t *testing.T) { created = append(created, wallet) } + pageReq := newTestReq[uint32](t, 2) + pageReq.After = uint32Ptr(created[1].ID) + query := db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(created[1].ID), - }, + Page: pageReq, } pageResult, err := store.ListWallets(t.Context(), query) @@ -428,7 +438,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.NoError(t, err) query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 1}, + Page: newTestReq[uint32](t, 1), } page1, err := store.ListWallets(t.Context(), query) @@ -471,7 +481,7 @@ func TestListWalletsDeterministicPagination(t *testing.T) { } pages := collectWalletPages(t, store, db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -539,7 +549,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) require.NoError(t, err) @@ -577,21 +587,21 @@ func TestListWalletsCursorEdges(t *testing.T) { require.NoError(t, err) } + staleReq := newTestReq[uint32](t, 2) + staleReq.After = uint32Ptr(math.MaxUint32) + stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(math.MaxUint32), - }, + Page: staleReq, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) + zeroReq := newTestReq[uint32](t, 2) + zeroReq.After = uint32Ptr(0) + zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(0), - }, + Page: zeroReq, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go index b864b87a9b..59465b91ba 100644 --- a/wallet/internal/db/page/doc.go +++ b/wallet/internal/db/page/doc.go @@ -4,7 +4,8 @@ // # Core types // // A [Request] carries the parameters for a single page fetch: page limit, -// and an optional after that identifies where the previous page ended. +// and an optional after that identifies where the previous page ended. Use +// [NewRequest] to construct a request with a positive page limit. // // A [Result] carries the items returned by one fetch together with // [Result.Next]. Assign [Result.Next] to [Request.After] to advance to the next diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go index 6adcd4759b..aaa4ca9d60 100644 --- a/wallet/internal/db/page/request.go +++ b/wallet/internal/db/page/request.go @@ -1,29 +1,48 @@ package page +import "errors" + +// ErrInvalidLimit is returned when a request is created with a zero page limit. +var ErrInvalidLimit = errors.New("page limit must be greater than zero") + // Request holds the parameters for a paginated list query. // -// Limit must be greater than zero. +// Use NewRequest to construct a request with a positive page limit. type Request[Cursor any] struct { - // Limit is the maximum number of items to return in one page. - Limit uint32 + limit uint32 - // After is the cursor identifying where the next page starts after. Nil - // means the request targets the first page. + // After is the cursor identifying where the next page starts after. + // Nil means the request targets the first page. After *Cursor } +// NewRequest returns a Request with the given positive page limit. +func NewRequest[Cursor any](limit uint32) (Request[Cursor], error) { + if limit == 0 { + return Request[Cursor]{}, ErrInvalidLimit + } + + return Request[Cursor]{limit: limit}, nil +} + +// Limit returns the configured page size. +func (r Request[Cursor]) Limit() uint32 { + return r.limit +} + // BuildResult assembles a page result from a slice of items already fetched by // the caller. // -// The caller must pass a positive limit. BuildResult trims an extra lookahead -// row when present and derives Next from the last retained item. -func BuildResult[Cursor, Item any](items []Item, limit uint32, +// BuildResult uses the request limit to trim an extra lookahead row when +// present and derives Next from the last retained item. +func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, nextOf func(Item) Cursor) Result[Item, Cursor] { if len(items) == 0 { return Result[Item, Cursor]{Items: items} } + limit := r.Limit() if len(items) <= int(limit) { return Result[Item, Cursor]{Items: items} } diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go index c64ab4e95f..267a7023fc 100644 --- a/wallet/internal/db/page/request_test.go +++ b/wallet/internal/db/page/request_test.go @@ -11,6 +11,44 @@ func intPtrRequest(v int) *int { return &v } +// TestNewRequest verifies NewRequest validates and stores the page limit. +func TestNewRequest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + limit uint32 + wantLimit uint32 + wantErr error + }{ + { + name: "zero limit rejected", + limit: 0, + wantErr: ErrInvalidLimit, + }, + { + name: "positive limit accepted", + limit: 5, + wantLimit: 5, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := NewRequest[int](tc.limit) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantLimit, req.Limit()) + }) + } +} + // TestBuildResult verifies BuildResult assembles the correct Result using the // lookahead row trimming and Next logic. func TestBuildResult(t *testing.T) { @@ -59,7 +97,10 @@ func TestBuildResult(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - result := BuildResult(tc.items, tc.limit, toCursor) + req, err := NewRequest[int](tc.limit) + require.NoError(t, err) + + result := BuildResult(req, tc.items, toCursor) require.Equal(t, tc.wantItems, result.Items) require.Equal(t, tc.wantNext, result.Next) diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 742029f934..fc16cd427c 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -37,7 +37,8 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - if query.Page.Limit == 0 { + + if query.Page.Limit() == 0 { return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -47,7 +48,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.AddressInfo) uint32 { return item.ID }, @@ -358,7 +359,7 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.Limit) + 1, + PageLimit: int64(q.Page.Limit()) + 1, } if q.Page.After != nil { diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 1e29566761..6b121eef15 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -147,7 +147,7 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - if query.Page.Limit == 0 { + if query.Page.Limit() == 0 { return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -169,7 +169,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.WalletInfo) uint32 { return item.ID }, @@ -326,7 +326,7 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.Limit) + 1, + PageLimit: int64(req.Limit()) + 1, } if req.After != nil { diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 0bd647be7c..838efde2b4 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -37,7 +37,8 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - if query.Page.Limit == 0 { + + if query.Page.Limit() == 0 { return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -47,7 +48,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.AddressInfo) uint32 { return item.ID }, @@ -354,7 +355,7 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.Limit) + 1, + PageLimit: int64(q.Page.Limit()) + 1, } if q.Page.After != nil { diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 16a2bbc0a4..03d1fd2707 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -147,7 +147,7 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - if query.Page.Limit == 0 { + if query.Page.Limit() == 0 { return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -171,7 +171,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.WalletInfo) uint32 { return item.ID }, @@ -329,7 +329,7 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.Limit) + 1, + PageLimit: int64(req.Limit()) + 1, } if req.After != nil { From 8a97c6aace68e166e896a7e07ecf350e9c65bd77 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 11:15:59 -0300 Subject: [PATCH 237/695] wallet: cleanup import aliases --- sqlc.yaml | 4 +-- wallet/internal/db/db_connectors_test.go | 30 +++++++++---------- wallet/internal/db/itest/fixtures_pg_test.go | 8 ++--- .../internal/db/itest/fixtures_sqlite_test.go | 8 ++--- wallet/internal/db/itest/pg_test.go | 20 ++++++------- wallet/internal/db/itest/sqlite_test.go | 20 ++++++------- .../db/itest/tx_corruption_pg_test.go | 12 ++++---- .../db/itest/tx_corruption_sqlite_test.go | 12 ++++---- .../db/itest/tx_store_corruption_test.go | 6 ++-- .../db/itest/utxo_store_edge_cases_test.go | 4 +-- wallet/internal/db/pg/accounts.go | 4 +-- wallet/internal/db/pg/address_types.go | 4 +-- wallet/internal/db/pg/addresses.go | 4 +-- wallet/internal/db/pg/backend_error_test.go | 4 +-- wallet/internal/db/pg/backend_rows_test.go | 4 +-- wallet/internal/db/pg/block.go | 4 +-- wallet/internal/db/pg/config.go | 2 +- wallet/internal/db/pg/config_test.go | 2 +- wallet/internal/db/pg/itest.go | 8 ++--- wallet/internal/db/pg/store.go | 8 ++--- wallet/internal/db/pg/testhelpers_test.go | 2 +- wallet/internal/db/pg/txstore_createtx.go | 4 +-- wallet/internal/db/pg/txstore_deletetx.go | 4 +-- wallet/internal/db/pg/txstore_gettx.go | 4 +-- .../db/pg/txstore_invalidateunmined.go | 4 +-- wallet/internal/db/pg/txstore_listtxns.go | 4 +-- wallet/internal/db/pg/txstore_rollback.go | 4 +-- wallet/internal/db/pg/txstore_updatetx.go | 4 +-- wallet/internal/db/pg/utxostore_balance.go | 4 +-- wallet/internal/db/pg/utxostore_getutxo.go | 4 +-- .../internal/db/pg/utxostore_leaseoutput.go | 4 +-- .../db/pg/utxostore_listleasedoutputs.go | 4 +-- wallet/internal/db/pg/utxostore_listutxos.go | 4 +-- .../internal/db/pg/utxostore_releaseoutput.go | 4 +-- wallet/internal/db/pg/wallet.go | 4 +-- wallet/internal/db/sqlite/accounts.go | 4 +-- wallet/internal/db/sqlite/address_types.go | 4 +-- wallet/internal/db/sqlite/addresses.go | 4 +-- .../internal/db/sqlite/backend_error_test.go | 4 +-- .../internal/db/sqlite/backend_rows_test.go | 4 +-- wallet/internal/db/sqlite/block.go | 4 +-- wallet/internal/db/sqlite/config.go | 2 +- wallet/internal/db/sqlite/config_test.go | 2 +- wallet/internal/db/sqlite/itest.go | 8 ++--- wallet/internal/db/sqlite/store.go | 8 ++--- wallet/internal/db/sqlite/testhelpers_test.go | 2 +- wallet/internal/db/sqlite/txstore_createtx.go | 4 +-- wallet/internal/db/sqlite/txstore_deletetx.go | 4 +-- wallet/internal/db/sqlite/txstore_gettx.go | 4 +-- .../db/sqlite/txstore_invalidateunmined.go | 4 +-- wallet/internal/db/sqlite/txstore_listtxns.go | 4 +-- wallet/internal/db/sqlite/txstore_rollback.go | 4 +-- wallet/internal/db/sqlite/txstore_updatetx.go | 4 +-- .../internal/db/sqlite/utxostore_balance.go | 4 +-- .../internal/db/sqlite/utxostore_getutxo.go | 4 +-- .../db/sqlite/utxostore_leaseoutput.go | 4 +-- .../db/sqlite/utxostore_listleasedoutputs.go | 4 +-- .../internal/db/sqlite/utxostore_listutxos.go | 4 +-- .../db/sqlite/utxostore_releaseoutput.go | 4 +-- wallet/internal/db/sqlite/wallet.go | 4 +-- wallet/internal/sql/pg/sqlc/accounts.sql.go | 2 +- .../internal/sql/pg/sqlc/address_types.sql.go | 2 +- wallet/internal/sql/pg/sqlc/addresses.sql.go | 2 +- wallet/internal/sql/pg/sqlc/blocks.sql.go | 2 +- wallet/internal/sql/pg/sqlc/db.go | 2 +- wallet/internal/sql/pg/sqlc/key_scopes.sql.go | 2 +- wallet/internal/sql/pg/sqlc/models.go | 2 +- wallet/internal/sql/pg/sqlc/querier.go | 2 +- .../internal/sql/pg/sqlc/transactions.sql.go | 2 +- .../sql/pg/sqlc/tx_replacements.sql.go | 2 +- .../internal/sql/pg/sqlc/utxo_leases.sql.go | 2 +- wallet/internal/sql/pg/sqlc/utxos.sql.go | 2 +- wallet/internal/sql/pg/sqlc/wallets.sql.go | 2 +- .../internal/sql/sqlite/sqlc/accounts.sql.go | 2 +- .../sql/sqlite/sqlc/address_types.sql.go | 2 +- .../internal/sql/sqlite/sqlc/addresses.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/blocks.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/db.go | 2 +- .../sql/sqlite/sqlc/key_scopes.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/models.go | 2 +- wallet/internal/sql/sqlite/sqlc/querier.go | 2 +- .../sql/sqlite/sqlc/transactions.sql.go | 2 +- .../sql/sqlite/sqlc/tx_replacements.sql.go | 2 +- .../sql/sqlite/sqlc/utxo_leases.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/utxos.sql.go | 2 +- .../internal/sql/sqlite/sqlc/wallets.sql.go | 2 +- wallet/mock_test.go | 2 +- wallet/utxo_manager.go | 2 +- wallet/utxo_manager_test.go | 2 +- wallet/wallet.go | 2 +- 90 files changed, 194 insertions(+), 194 deletions(-) diff --git a/sqlc.yaml b/sqlc.yaml index 1ee7f931f1..21de01f0d0 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -8,7 +8,7 @@ sql: gen: go: out: "wallet/internal/sql/pg/sqlc" - package: "sqlcpg" + package: "sqlc" # This is the driver package that sqlc will use in the generated code. sql_package: database/sql @@ -29,7 +29,7 @@ sql: gen: go: out: "wallet/internal/sql/sqlite/sqlc" - package: "sqlcsqlite" + package: "sqlc" # This is the driver package that sqlc will use in the generated code. sql_package: database/sql diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 9185bf8d74..f80473b1a8 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -4,9 +4,9 @@ import ( "path/filepath" "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -15,19 +15,19 @@ func TestPostgresNewStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg dbpg.Config + cfg pg.Config wantErr error }{ { name: "empty DSN", - cfg: dbpg.Config{ + cfg: pg.Config{ Dsn: "", }, wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: dbpg.Config{ + cfg: pg.Config{ Dsn: "postgres://test", MaxConnections: -1, }, @@ -39,7 +39,7 @@ func TestPostgresNewStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbpg.NewStore(t.Context(), tc.cfg) + store, err := pg.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -50,11 +50,11 @@ func TestPostgresNewStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := dbpg.Config{ + cfg := pg.Config{ Dsn: "postgres://localhost:1/testdb", } - store, err := dbpg.NewStore(t.Context(), cfg) + store, err := pg.NewStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) @@ -70,19 +70,19 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg dbsqlite.Config + cfg sqlite.Config wantErr error }{ { name: "empty DB path", - cfg: dbsqlite.Config{ + cfg: sqlite.Config{ DBPath: "", }, wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: dbsqlite.Config{ + cfg: sqlite.Config{ DBPath: "/tmp/test.db", MaxConnections: -1, }, @@ -94,7 +94,7 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbsqlite.NewStore(t.Context(), tc.cfg) + store, err := sqlite.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -104,11 +104,11 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { func TestSQLiteNewStoreSuccess(t *testing.T) { t.Parallel() - cfg := dbsqlite.Config{ + cfg := sqlite.Config{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := dbsqlite.NewStore(t.Context(), cfg) + store, err := sqlite.NewStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 924e1ae3c5..c029000305 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbpg.Store{}, store) + require.IsType(t, &pg.Store{}, store) - pgStore := store.(*dbpg.Store) + pgStore := store.(*pg.Store) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 634dcb0a4b..d2f2475e72 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbsqlite.Store{}, store) + require.IsType(t, &sqlite.Store{}, store) - sqliteStore := store.(*dbsqlite.Store) + sqliteStore := store.(*sqlite.Store) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 92a4a962a8..467822e6f3 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -20,8 +20,8 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -193,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *dbpg.Store { +func NewTestStore(t *testing.T) *pg.Store { t.Helper() ctx := t.Context() @@ -223,12 +223,12 @@ func NewTestStore(t *testing.T) *dbpg.Store { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - cfg := dbpg.Config{ + cfg := pg.Config{ Dsn: testConnStr, MaxConnections: 0, } - store, err := dbpg.NewStore(t.Context(), cfg) + store, err := pg.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -240,7 +240,7 @@ func NewTestStore(t *testing.T) *dbpg.Store { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbpg.Store, +func childSpendingTxIDs(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) []int64 { @@ -273,7 +273,7 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.Store, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, +func txIDByHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -297,7 +297,7 @@ func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, +func rawTxByHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -315,7 +315,7 @@ func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, +func setTxStatus(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -336,7 +336,7 @@ func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbpg.Store, +func walletUtxoExists(t *testing.T, store *pg.Store, walletID uint32, outPoint wire.OutPoint) bool { diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 8d54ab96c2..e65169565f 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -11,25 +11,25 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *dbsqlite.Store { +func NewTestStore(t *testing.T) *sqlite.Store { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - cfg := dbsqlite.Config{ + cfg := sqlite.Config{ DBPath: dbPath, MaxConnections: 0, } - store, err := dbsqlite.NewStore(t.Context(), cfg) + store, err := sqlite.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -41,7 +41,7 @@ func NewTestStore(t *testing.T) *dbsqlite.Store { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, +func childSpendingTxIDs(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) []int64 { @@ -74,7 +74,7 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, +func txIDByHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -98,7 +98,7 @@ func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, +func rawTxByHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -116,7 +116,7 @@ func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, +func setTxStatus(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -137,7 +137,7 @@ func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbsqlite.Store, +func walletUtxoExists(t *testing.T, store *sqlite.Store, walletID uint32, outPoint wire.OutPoint) bool { diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index 3e3eac72b0..2a858404a4 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -19,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbpg.Store, +func corruptTransactionStatus(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -49,7 +49,7 @@ func corruptTransactionStatus(t *testing.T, store *dbpg.Store, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbpg.Store, +func corruptTransactionHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -75,7 +75,7 @@ func corruptTransactionHash(t *testing.T, store *dbpg.Store, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, +func corruptTransactionBlockHeight(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -110,7 +110,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, +func corruptUtxoOutputIndex(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -136,7 +136,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbpg.Store, +func corruptActiveLeaseLockID(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index 6a05e1497c..eb94c24ec1 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -19,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, +func corruptTransactionStatus(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -54,7 +54,7 @@ func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, +func corruptTransactionHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -89,7 +89,7 @@ func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, +func corruptTransactionBlockHeight(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -134,7 +134,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, +func corruptUtxoOutputIndex(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -170,7 +170,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.Store, +func corruptActiveLeaseLockID(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index 9e88befe39..fc3026bf5e 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -22,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*dbpg.Store); ok { + if _, ok := any(store).(*pg.Store); ok { stmt += " CASCADE" } @@ -492,7 +492,7 @@ func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. // PostgreSQL drops those dependent rows with CASCADE when the blocks table is // removed, so rollback gets far enough to fail on the block delete instead. - _, ok := any(store).(*dbpg.Store) + _, ok := any(store).(*pg.Store) if ok { require.ErrorContains(t, err, "delete blocks at or above height") return diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index 2b264fddb1..0bd337bfe1 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -258,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*dbpg.Store); ok { + if _, ok := any(store).(*pg.Store); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 7e1503b789..180a5efeec 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure Store satisfies the AccountStore interface. diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index fbbc06f9bf..919bfb6c98 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -3,8 +3,8 @@ package pg import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // addressTypeRowToInfo converts a PostgreSQL address type row to an diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index fc16cd427c..d859b095dd 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -7,9 +7,9 @@ import ( "iter" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) var _ db.AddressStore = (*Store)(nil) diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index 9d3d03daab..e6dbba9a1b 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -9,8 +9,8 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index bc86fd1494..b81e032462 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -7,8 +7,8 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 8f906eb2f7..e8ea756994 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildBlock constructs a Block from the given PostgreSQL block diff --git a/wallet/internal/db/pg/config.go b/wallet/internal/db/pg/config.go index 3046c040c6..8a9aed180d 100644 --- a/wallet/internal/db/pg/config.go +++ b/wallet/internal/db/pg/config.go @@ -3,7 +3,7 @@ package pg import ( "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/jackc/pgx/v5" ) diff --git a/wallet/internal/db/pg/config_test.go b/wallet/internal/db/pg/config_test.go index 1cf5fdcf93..9787217a9f 100644 --- a/wallet/internal/db/pg/config_test.go +++ b/wallet/internal/db/pg/config_test.go @@ -3,7 +3,7 @@ package pg import ( "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 0a4f62b514..2255ace7f3 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -5,8 +5,8 @@ package pg import ( "database/sql" - sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. @@ -21,10 +21,10 @@ func (s *Store) Queries() *sqlc.Queries { // RollbackAllMigrations rolls back all PostgreSQL migrations. func (s *Store) RollbackAllMigrations() error { - return sqlassetpg.RollbackMigrations(s.db) + return pg.RollbackMigrations(s.db) } // ApplyAllMigrations reapplies all PostgreSQL migrations. func (s *Store) ApplyAllMigrations() error { - return sqlassetpg.ApplyMigrations(s.db) + return pg.ApplyMigrations(s.db) } diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 2b7e4c3a0a..4b734d84c0 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -5,9 +5,9 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -54,7 +54,7 @@ func NewStore(ctx context.Context, cfg Config) (*Store, queries := sqlc.New(dbConn) - err = sqlassetpg.ApplyMigrations(dbConn) + err = pg.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go index a1ada7fde5..ee02de3928 100644 --- a/wallet/internal/db/pg/testhelpers_test.go +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index e90f2706b9..2f880d5882 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 7427a32f4e..e6e8500399 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index 50a117a789..c117d8fcef 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index ee26f79c32..d5fdc9aeac 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index c74bbb934f..222a69b2ea 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index e40822c41c..b74108d367 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 039cd0e477..fbb5a87c3b 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 6ebc92e633..7e88ad5ae0 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 03ecb4a8da..4a2b4f8114 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 211afd2123..d0f685e270 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 0d17605633..7df070a56b 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 9673ca15bf..39de82d47a 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 0876102448..4555f3b932 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 6b121eef15..e72d5c3662 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -7,9 +7,9 @@ import ( "fmt" "iter" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure Store satisfies the WalletStore interface. diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index e6315a4f21..b4507f8bcf 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure Store satisfies the AccountStore interface. diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index 1f39b389a7..ab12a86503 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -3,8 +3,8 @@ package sqlite import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // addressTypeRowToInfo converts a SQLite address type row to an diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 838efde2b4..33cfb386ae 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -7,9 +7,9 @@ import ( "iter" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) var _ db.AddressStore = (*Store)(nil) diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index 92e462f8db..b9ef714e99 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index f26c1f215f..d53d05ae94 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 1c4ddc78e4..a3f09dcb04 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildBlock constructs a Block from the given SQLite block diff --git a/wallet/internal/db/sqlite/config.go b/wallet/internal/db/sqlite/config.go index f9aba8086e..a069a01a9e 100644 --- a/wallet/internal/db/sqlite/config.go +++ b/wallet/internal/db/sqlite/config.go @@ -1,6 +1,6 @@ package sqlite -import db "github.com/btcsuite/btcwallet/wallet/internal/db" +import "github.com/btcsuite/btcwallet/wallet/internal/db" // Config holds the configuration for the SQLite database. type Config struct { diff --git a/wallet/internal/db/sqlite/config_test.go b/wallet/internal/db/sqlite/config_test.go index c3dc9c7e4c..cc378647e3 100644 --- a/wallet/internal/db/sqlite/config_test.go +++ b/wallet/internal/db/sqlite/config_test.go @@ -3,7 +3,7 @@ package sqlite import ( "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 623078d13f..7529e793f2 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,8 +5,8 @@ package sqlite import ( "database/sql" - sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. @@ -21,10 +21,10 @@ func (s *Store) Queries() *sqlc.Queries { // RollbackAllMigrations rolls back all SQLite migrations. func (s *Store) RollbackAllMigrations() error { - return sqlassetsqlite.RollbackMigrations(s.db) + return sqlite.RollbackMigrations(s.db) } // ApplyAllMigrations reapplies all SQLite migrations. func (s *Store) ApplyAllMigrations() error { - return sqlassetsqlite.ApplyMigrations(s.db) + return sqlite.ApplyMigrations(s.db) } diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index 6f08363d82..a93ed3fd72 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -5,9 +5,9 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) @@ -60,7 +60,7 @@ func NewStore(ctx context.Context, cfg Config) (*Store, queries := sqlc.New(dbConn) - err = sqlassetsqlite.ApplyMigrations(dbConn) + err = sqlite.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go index 09e252ff8c..411d8d6279 100644 --- a/wallet/internal/db/sqlite/testhelpers_test.go +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -10,7 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 28db65c5bd..120f658a7f 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 1cbd388095..0fc03879b3 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 955d0a71a5..9870a85772 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 4938dea170..569fc4b790 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index bcfbe12bc3..9581a9dfc5 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index ae48e12c14..6dcf07bb63 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index eb833407e9..43f202dfa2 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index 4caeb5fc12..ce432db4f5 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/v2" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index d5bc3b60cf..c470041807 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index cbe2c2b3b2..1be9ccb8a7 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 68705d245d..b7a6317dcb 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 03c1d082d2..2fa7e94e88 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index 5cfad1d1e6..e2a453c3d0 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 03d1fd2707..0e3f29b6b2 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -7,9 +7,9 @@ import ( "fmt" "iter" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure Store satisfies the WalletStore interface. diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index 894cd597d1..bfd09a6879 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: accounts.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/address_types.sql.go b/wallet/internal/sql/pg/sqlc/address_types.sql.go index 86e7a4a531..eeb9d1f895 100644 --- a/wallet/internal/sql/pg/sqlc/address_types.sql.go +++ b/wallet/internal/sql/pg/sqlc/address_types.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: address_types.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index 3ffd8b851a..d6ce305551 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: addresses.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/blocks.sql.go b/wallet/internal/sql/pg/sqlc/blocks.sql.go index b5f83978cb..41071c7263 100644 --- a/wallet/internal/sql/pg/sqlc/blocks.sql.go +++ b/wallet/internal/sql/pg/sqlc/blocks.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: blocks.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index 19e8837f3d..b40f806aa3 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/key_scopes.sql.go b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go index 2afd0e7aaa..39608fd021 100644 --- a/wallet/internal/sql/pg/sqlc/key_scopes.sql.go +++ b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: key_scopes.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 363466e546..9173419113 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "database/sql" diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 2fc726dbc0..963865d03c 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go index c4406f509d..0c30cb96ea 100644 --- a/wallet/internal/sql/pg/sqlc/transactions.sql.go +++ b/wallet/internal/sql/pg/sqlc/transactions.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: transactions.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go index c536d3710b..b067298c53 100644 --- a/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go +++ b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: tx_replacements.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go index 607e95362e..660a915a13 100644 --- a/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go +++ b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxo_leases.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/utxos.sql.go b/wallet/internal/sql/pg/sqlc/utxos.sql.go index 7eb9ab54c9..54aa0f53d6 100644 --- a/wallet/internal/sql/pg/sqlc/utxos.sql.go +++ b/wallet/internal/sql/pg/sqlc/utxos.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxos.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/wallets.sql.go b/wallet/internal/sql/pg/sqlc/wallets.sql.go index 91055c9f90..368b97957b 100644 --- a/wallet/internal/sql/pg/sqlc/wallets.sql.go +++ b/wallet/internal/sql/pg/sqlc/wallets.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: wallets.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index f50130e742..481ede7af2 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: accounts.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/address_types.sql.go b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go index 5aa5cfdd5b..fb9b4de436 100644 --- a/wallet/internal/sql/sqlite/sqlc/address_types.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: address_types.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index fbf4baf2e2..cd60257e8e 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: addresses.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go index decb094d32..a154122ed8 100644 --- a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: blocks.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index d5fa946fc3..ce1ff24b00 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go index 7305a3c014..7b102fa56d 100644 --- a/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: key_scopes.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index a08fb8d7a7..053824aa10 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "database/sql" diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index bebc1585c0..8586c40cc1 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go index 07ef498aa1..fe9cde14c3 100644 --- a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: transactions.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go index a4a2cd60ce..9397074b51 100644 --- a/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: tx_replacements.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go index 3fc475ca72..2a85cb3a58 100644 --- a/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxo_leases.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go index f8b5e110d5..137407be77 100644 --- a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxos.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go index 24077a7cae..13f890a6ca 100644 --- a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: wallets.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 5240618ad2..8d3f12f01a 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -22,7 +22,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 4d1aa4e661..3771617d2c 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -20,7 +20,7 @@ import ( "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 7fdabd241e..3e7dbadfb3 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -13,7 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/wallet/wallet.go b/wallet/wallet.go index afe1617635..b27755c008 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -24,7 +24,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) From 8882a016eabaf11c42e212b74485b8d1edbc8d13 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:43:07 +0800 Subject: [PATCH 238/695] db/err: add shared SQL error types --- wallet/internal/db/err/errors.go | 441 ++++++++++++++++++++++++++ wallet/internal/db/err/errors_test.go | 270 ++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 wallet/internal/db/err/errors.go create mode 100644 wallet/internal/db/err/errors_test.go diff --git a/wallet/internal/db/err/errors.go b/wallet/internal/db/err/errors.go new file mode 100644 index 0000000000..ee6654f342 --- /dev/null +++ b/wallet/internal/db/err/errors.go @@ -0,0 +1,441 @@ +// Package dberr contains the shared SQL error taxonomy and normalization logic +// used by backend-specific db packages. +package dberr + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "net" +) + +// unknownString is the fallback text for invalid enum values. +const unknownString = "unknown" + +// Backend identifies which SQL backend produced a classified SQL error. +// +// The backend is preserved on SQLError so callers can log raw backend codes, +// maintain per-backend stats, and keep backend-specific handling separate from +// the shared error model. +type Backend string + +// Backend constants identify supported SQL backends. +const ( + // BackendPostgres identifies the PostgreSQL SQL backend. + BackendPostgres Backend = "postgres" + + // BackendSQLite identifies the SQLite SQL backend. + BackendSQLite Backend = "sqlite" +) + +// String returns the canonical string form of the backend. +func (b Backend) String() string { + switch b { + case BackendPostgres: + return string(b) + + case BackendSQLite: + return string(b) + + default: + return unknownString + } +} + +// Valid reports whether the backend is one of the known SQL backends. +func (b Backend) Valid() bool { + switch b { + case BackendPostgres: + return true + + case BackendSQLite: + return true + + default: + return false + } +} + +// Class describes the caller-facing policy bucket for a classified SQL backend +// error. +// +// The class answers what runtime code should do next: +// +// - Transient: retry the operation when it is safe to retry. +// - Permanent: fail the operation immediately without poisoning the store. +// - Fatal: fail the operation and mark the store unhealthy so later calls +// fail fast until the backend is reopened or repaired. +type Class uint8 + +// Class constants identify caller-facing SQL error policy buckets. +const ( + // ClassTransient marks failures that may succeed on a later retry. + ClassTransient Class = iota + + // ClassPermanent marks failures that should fail the current + // operation without retrying or poisoning the store. + ClassPermanent + + // ClassFatal marks failures that should fail the current operation + // and take the store out of service. + ClassFatal +) + +// String returns the canonical string form of the class. +func (c Class) String() string { + switch c { + case ClassTransient: + return "transient" + + case ClassPermanent: + return "permanent" + + case ClassFatal: + return "fatal" + + default: + return unknownString + } +} + +// Valid reports whether the class is recognized. +func (c Class) Valid() bool { + switch c { + case ClassTransient: + return true + + case ClassPermanent: + return true + + case ClassFatal: + return true + + default: + return false + } +} + +// Reason describes the specific backend failure bucket for a classified SQL +// error. +// +// Callers use Reason for diagnosis and metrics, then derive runtime policy +// through Class(). New or unmapped backend codes should fall back to +// ReasonUnknown while preserving the raw backend code on SQLError. +type Reason uint8 + +// Reason constants identify shared SQL backend failure buckets. +const ( + // ReasonUnknown is the fallback reason for uncategorized backend codes. + ReasonUnknown Reason = iota + + // ReasonSerialization marks transaction serialization failures + // caused by concurrent updates. + ReasonSerialization + + // ReasonDeadlock marks deadlock failures where the backend aborts + // one participant to break a lock cycle. + ReasonDeadlock + + // ReasonBusy marks SQLite busy failures where a lock cannot be + // acquired in time. + ReasonBusy + + // ReasonLocked marks lock-not-available failures. + ReasonLocked + + // ReasonUnavailable marks backend availability and transport + // failures. + ReasonUnavailable + + // ReasonPoolExhausted marks connection-pool or backend + // connection-limit failures. + ReasonPoolExhausted + + // ReasonSchemaMismatch marks schema drift or schema version + // mismatches. + ReasonSchemaMismatch + + // ReasonConstraint marks backend constraint failures. + ReasonConstraint + + // ReasonResourceExhausted marks disk, memory, or similar hard + // resource failures. + ReasonResourceExhausted + + // ReasonReadOnly marks read-only backend failures. + ReasonReadOnly + + // ReasonPermission marks backend permission failures. + ReasonPermission + + // ReasonCorrupt marks corruption failures. + ReasonCorrupt +) + +// Class returns the caller-facing policy bucket derived from the reason. +func (r Reason) Class() Class { + switch r { + case ReasonSerialization, ReasonDeadlock, ReasonBusy, ReasonLocked, + ReasonUnavailable, ReasonPoolExhausted: + + return ClassTransient + + // Unknown or caller-fixable backend states fail the current operation + // without retrying or taking the store out of service. + case ReasonSchemaMismatch, ReasonConstraint, ReasonUnknown: + return ClassPermanent + + case ReasonResourceExhausted, ReasonReadOnly, ReasonPermission, + ReasonCorrupt: + + return ClassFatal + + default: + return ClassPermanent + } +} + +// String returns the canonical string form of the reason. +// +//nolint:cyclop // One explicit enum-to-string switch is the clearest form here. +func (r Reason) String() string { + switch r { + case ReasonSerialization: + return "serialization" + + case ReasonDeadlock: + return "deadlock" + + case ReasonBusy: + return "busy" + + case ReasonLocked: + return "locked" + + case ReasonUnavailable: + return "unavailable" + + case ReasonPoolExhausted: + return "pool_exhausted" + + case ReasonSchemaMismatch: + return "schema_mismatch" + + case ReasonConstraint: + return "constraint" + + case ReasonUnknown: + return unknownString + + case ReasonResourceExhausted: + return "resource_exhausted" + + case ReasonReadOnly: + return "read_only" + + case ReasonPermission: + return "permission" + + case ReasonCorrupt: + return "corrupt" + + default: + return unknownString + } +} + +// Valid reports whether the reason is recognized. +func (r Reason) Valid() bool { + return r <= ReasonCorrupt +} + +// SQLError wraps a backend or transport error with stable SQL classification +// metadata. +// +// The wrapper keeps classification data inside ordinary error chains so callers +// can recover backend, reason, class, and raw codes with errors.As after higher +// layers add their own context. +type SQLError struct { + // Backend identifies which SQL backend produced the error. + Backend Backend + + // Reason identifies the specific backend failure bucket. + Reason Reason + + // Code stores the raw backend code used for classification. + Code string + + // Err stores the wrapped backend or transport error. + Err error +} + +// NewSQLError builds a classified SQL backend error wrapper. +func NewSQLError(backend Backend, reason Reason, code string, + err error) *SQLError { + + return &SQLError{ + Backend: backend, + Reason: reason, + Code: code, + Err: err, + } +} + +// Class returns the caller-facing policy bucket derived from the reason. +func (e *SQLError) Class() Class { + if e == nil { + // A missing wrapper is treated like an unknown classified error so + // defensive callers do not retry or poison store state based on + // absent classification data. + return ReasonUnknown.Class() + } + + return e.Reason.Class() +} + +// Error returns the printable form of the wrapped backend error. +func (e *SQLError) Error() string { + if e == nil { + return "" + } + + // Normal wrappers carry the backend or transport error, so preserve that + // message and leave the remaining branches as defensive fallbacks. + if e.Err != nil { + return e.Err.Error() + } + + // Fall back to synthesized text only for malformed or test-built + // wrappers that do not carry an underlying cause. + return fmt.Sprintf("%s %s sql error", e.Backend.String(), e.Reason) +} + +// Unwrap returns the wrapped backend error. +func (e *SQLError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +// Normalize converts one backend error into the shared SQL error model. +// +// It handles errors in this order: +// +// 1. A nil error stays nil. +// 2. Context cancellation and deadline errors are returned unchanged, +// including any caller-added wrapping context. +// 3. Existing SQLError wrappers are returned unchanged so Normalize does not +// rebuild or strip the original error chain. +// 4. The mapper gets the first chance to translate backend-native driver +// errors into SQLError values. +// 5. Generic connection and transport failures are classified as +// ReasonUnavailable. +// 6. If the backend is known and no earlier rule matches, err is wrapped as +// ReasonUnknown. +// 7. If the backend is unknown, err is returned unchanged. +// +// Normalize is intentionally side-effect free. Callers own follow-up actions +// such as stats recording or unhealthy-store transitions. +// +// The mapper keeps backend-specific matching outside the shared error package +// so backend packages can return shared SQL errors without introducing import +// cycles. Callers should pass a no-op mapper when they have no backend- +// specific codes to inspect. +func Normalize(backend Backend, mapper func(error) *SQLError, err error) error { + if err == nil { + return nil + } + + // Preserve caller-driven cancellation semantics, including any wrapping + // context, instead of reclassifying them as backend errors. + contextErr := unwrapContextErr(err) + if contextErr != nil { + return contextErr + } + + // Preserve existing SQL wrappers so caller-added context is not dropped + // from the original error chain. + if extractSQLError(err) != nil { + return err + } + + // Ask the backend-specific mapper first so driver-native codes stay as + // specific as possible. + sqlErr := mapper(err) + if sqlErr != nil { + return sqlErr + } + + // Fall back to transport-level classification for connection-oriented + // failures that are not backend-code specific. + transportErr := classifyConnErr(backend, err) + if transportErr != nil { + return transportErr + } + + // Unknown backends are left untouched so callers do not accidentally invent + // a backend identity the error never had. + if !backend.Valid() { + return err + } + + // Keep backend identity even when the exact backend code is not recognized + // so logs and stats retain useful diagnosis data. + return NewSQLError(backend, ReasonUnknown, "", err) +} + +// extractSQLError extracts the shared SQL error wrapper from err, if present. +func extractSQLError(err error) *SQLError { + var sqlErr *SQLError + if errors.As(err, &sqlErr) { + return sqlErr + } + + return nil +} + +// classifyConnErr classifies generic connection and transport failures that do +// not come with backend-specific codes. +func classifyConnErr(backend Backend, err error) *SQLError { + if !backend.Valid() { + return nil + } + + badConn := errors.Is(err, driver.ErrBadConn) + connDone := errors.Is(err, sql.ErrConnDone) + + // Broken-connection sentinels and EOF all mean the caller lost the SQL + // transport path before receiving a normal backend result. + if badConn || connDone || errors.Is(err, io.EOF) { + return NewSQLError(backend, ReasonUnavailable, "", err) + } + + var netErr net.Error + if errors.As(err, &netErr) { + // Timeout-only net errors are still transport failures even when the + // backend never returned a backend-specific code. + if netErr.Timeout() { + return NewSQLError(backend, ReasonUnavailable, "", err) + } + } + + return nil +} + +// unwrapContextErr preserves caller-driven context cancellation and deadlines. +func unwrapContextErr(err error) error { + if errors.Is(err, context.Canceled) { + return err + } + + if errors.Is(err, context.DeadlineExceeded) { + return err + } + + return nil +} diff --git a/wallet/internal/db/err/errors_test.go b/wallet/internal/db/err/errors_test.go new file mode 100644 index 0000000000..9a4a9b6024 --- /dev/null +++ b/wallet/internal/db/err/errors_test.go @@ -0,0 +1,270 @@ +package dberr + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +var errConstraint = errors.New("constraint") + +// noOpMapper is a test helper that leaves backend-specific classification to +// later Normalize fallbacks. +func noOpMapper(error) *SQLError { + return nil +} + +// TestBackendString verifies the string forms of the SQL backend identifiers. +func TestBackendString(t *testing.T) { + t.Parallel() + + require.Equal(t, "postgres", BackendPostgres.String()) + require.Equal(t, "sqlite", BackendSQLite.String()) + require.Equal(t, unknownString, Backend("mysql").String()) + require.True(t, BackendPostgres.Valid()) + require.False(t, Backend("mysql").Valid()) +} + +// TestClassString verifies the string forms of the exported error classes. +func TestClassString(t *testing.T) { + t.Parallel() + + require.Equal(t, "transient", ClassTransient.String()) + require.Equal(t, "permanent", ClassPermanent.String()) + require.Equal(t, "fatal", ClassFatal.String()) + require.Equal(t, unknownString, Class(99).String()) + require.True(t, ClassFatal.Valid()) + require.False(t, Class(99).Valid()) +} + +// TestReasonMetadata verifies the string and class metadata of the exported +// reasons. +func TestReasonMetadata(t *testing.T) { + t.Parallel() + + var zero Reason + require.Equal(t, ReasonUnknown, zero) + + require.Equal(t, "serialization", ReasonSerialization.String()) + require.Equal(t, ClassTransient, ReasonBusy.Class()) + require.Equal(t, ClassPermanent, ReasonUnknown.Class()) + require.Equal(t, ClassFatal, ReasonCorrupt.Class()) + require.Equal(t, unknownString, Reason(99).String()) + require.False(t, Reason(99).Valid()) +} + +// TestReasonClassCoverage verifies that every valid reason maps to the +// expected caller-facing policy bucket. +func TestReasonClassCoverage(t *testing.T) { + t.Parallel() + + tests := map[Reason]Class{ + ReasonUnknown: ClassPermanent, + ReasonSerialization: ClassTransient, + ReasonDeadlock: ClassTransient, + ReasonBusy: ClassTransient, + ReasonLocked: ClassTransient, + ReasonUnavailable: ClassTransient, + ReasonPoolExhausted: ClassTransient, + ReasonSchemaMismatch: ClassPermanent, + ReasonConstraint: ClassPermanent, + ReasonResourceExhausted: ClassFatal, + ReasonReadOnly: ClassFatal, + ReasonPermission: ClassFatal, + ReasonCorrupt: ClassFatal, + } + + for reason := ReasonUnknown; reason <= ReasonCorrupt; reason++ { + wantClass, ok := tests[reason] + require.Truef(t, ok, "missing class expectation for reason %v", reason) + require.Equal(t, wantClass, reason.Class()) + } +} + +// TestSQLErrorFormatting verifies the formatting helpers on nil and populated +// SQL errors. +func TestSQLErrorFormatting(t *testing.T) { + t.Parallel() + + var nilErr *SQLError + require.Equal(t, "", nilErr.Error()) + require.NoError(t, nilErr.Unwrap()) + require.Equal(t, ClassPermanent, nilErr.Class()) + + err := &SQLError{ + Backend: BackendSQLite, + Reason: ReasonUnknown, + } + require.Equal(t, "sqlite unknown sql error", err.Error()) + require.NoError(t, err.Unwrap()) +} + +// TestExtractSQLErrorClass verifies that callers can recover a wrapped SQL +// error and inspect its derived class with the standard errors.As pattern. +func TestExtractSQLErrorClass(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", NewSQLError( + BackendPostgres, + ReasonSerialization, + "40001", + driver.ErrBadConn, + )) + + sqlErr := extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, ClassTransient, sqlErr.Class()) + + require.Nil(t, extractSQLError(errConstraint)) +} + +// TestExtractSQLError verifies that callers can recover structured SQL +// metadata with the standard errors.As pattern. +func TestExtractSQLError(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("outer: %w", NewSQLError( + BackendSQLite, + ReasonConstraint, + "2067", + errConstraint, + )) + + sqlErr := extractSQLError(wrapped) + require.NotNil(t, sqlErr) + require.Equal(t, BackendSQLite, sqlErr.Backend) + require.Equal(t, ReasonConstraint, sqlErr.Reason) + require.Equal(t, "2067", sqlErr.Code) + require.Equal(t, ClassPermanent, sqlErr.Class()) +} + +// TestClassifyConnErr verifies that generic connection failures are classified +// as transient availability problems. +func TestClassifyConnErr(t *testing.T) { + t.Parallel() + + err := classifyConnErr(BackendPostgres, driver.ErrBadConn) + require.NotNil(t, err) + require.Equal(t, BackendPostgres, err.Backend) + require.Equal(t, ReasonUnavailable, err.Reason) + require.Equal(t, ClassTransient, err.Class()) + + err = classifyConnErr(BackendSQLite, sql.ErrConnDone) + require.NotNil(t, err) + require.Equal(t, ReasonUnavailable, err.Reason) + + err = classifyConnErr(BackendSQLite, netTimeoutError{}) + require.NotNil(t, err) + require.Equal(t, ReasonUnavailable, err.Reason) + + err = classifyConnErr(BackendSQLite, temporaryNetError{err: io.EOF}) + require.Nil(t, err) + + require.Nil(t, classifyConnErr(Backend(""), driver.ErrBadConn)) + require.Nil(t, classifyConnErr(BackendSQLite, sql.ErrNoRows)) +} + +// TestCoreHelpers verifies the remaining backend-agnostic helper branches. +func TestCoreHelpers(t *testing.T) { + t.Parallel() + + require.Equal(t, context.Canceled, unwrapContextErr(context.Canceled)) + require.Equal(t, context.DeadlineExceeded, + unwrapContextErr(context.DeadlineExceeded)) + + wrappedCanceled := fmt.Errorf("query accounts: %w", context.Canceled) + require.Same(t, wrappedCanceled, unwrapContextErr(wrappedCanceled)) + require.NoError(t, unwrapContextErr(sql.ErrNoRows)) +} + +// TestNormalize verifies the backend-aware normalization flow that keeps +// context errors untouched and preserves backend-specific mapping. +func TestNormalize(t *testing.T) { + t.Parallel() + + err := Normalize(BackendPostgres, noOpMapper, context.Canceled) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, extractSQLError(err)) + + wrappedCanceled := fmt.Errorf("query accounts: %w", context.Canceled) + err = Normalize(BackendPostgres, noOpMapper, wrappedCanceled) + require.Same(t, wrappedCanceled, err) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, extractSQLError(err)) + + err = Normalize(BackendPostgres, noOpMapper, driver.ErrBadConn) + + sqlErr := extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, BackendPostgres, sqlErr.Backend) + require.Equal(t, ClassTransient, sqlErr.Class()) + require.Equal(t, ReasonUnavailable, sqlErr.Reason) + + err = Normalize(BackendSQLite, func(in error) *SQLError { + return NewSQLError(BackendSQLite, ReasonUnknown, "", in) + }, errConstraint) + sqlErr = extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, ClassPermanent, sqlErr.Class()) + require.Equal(t, ReasonUnknown, sqlErr.Reason) + + existing := NewSQLError(Backend(""), ReasonCorrupt, "", io.EOF) + err = Normalize(BackendPostgres, noOpMapper, existing) + require.Same(t, existing, err) + require.Equal(t, Backend(""), extractSQLError(err).Backend) + + wrapped := fmt.Errorf("outer: %w", existing) + err = Normalize(BackendSQLite, noOpMapper, wrapped) + require.Same(t, wrapped, err) + require.Equal(t, Backend(""), extractSQLError(err).Backend) + + require.Same(t, errConstraint, + Normalize(Backend(""), noOpMapper, errConstraint)) +} + +// temporaryNetError is a test helper that exposes only the legacy Temporary +// signal without reporting a timeout. +type temporaryNetError struct { + // err is the wrapped error string returned by Error. + err error +} + +// Error returns the wrapped error string. +func (e temporaryNetError) Error() string { + return e.err.Error() +} + +// Timeout reports that the test error is not a timeout. +func (e temporaryNetError) Timeout() bool { + return false +} + +// Temporary reports that the test error should be treated as temporary. +func (e temporaryNetError) Temporary() bool { + return true +} + +// netTimeoutError is a test helper that reports a timeout without a legacy +// Temporary method. +type netTimeoutError struct{} + +// Error returns the static timeout error string. +func (e netTimeoutError) Error() string { + return "timeout" +} + +// Timeout reports that the test error is a timeout. +func (e netTimeoutError) Timeout() bool { + return true +} + +// Temporary reports that the test error is not a legacy temporary error. +func (e netTimeoutError) Temporary() bool { + return false +} From 1586cf5243ee068a24607934de50a4255d758321 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:43:32 +0800 Subject: [PATCH 239/695] db/pg: add SQL error mapper --- wallet/internal/db/pg/errors.go | 139 +++++++++++++++++++++++++++ wallet/internal/db/pg/errors_test.go | 118 +++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 wallet/internal/db/pg/errors.go create mode 100644 wallet/internal/db/pg/errors_test.go diff --git a/wallet/internal/db/pg/errors.go b/wallet/internal/db/pg/errors.go new file mode 100644 index 0000000000..1af2b8b158 --- /dev/null +++ b/wallet/internal/db/pg/errors.go @@ -0,0 +1,139 @@ +// Package pg contains PostgreSQL-specific SQL helpers. +package pg + +import ( + "errors" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" +) + +// SQLSTATE helper constants support PostgreSQL error classification. +const ( + // connectionExceptionClass identifies PostgreSQL SQLSTATE class 08, + // which covers connection exceptions. + connectionExceptionClass = "08" + + // sqlStateClassLen is the length of a SQLSTATE class prefix. + sqlStateClassLen = 2 +) + +// SQLSTATE code constants capture the PostgreSQL errors mapped here. +const ( + // The SQLSTATE codes below follow PostgreSQL's documented error code + // appendix. The wallet intentionally collapses many backend-specific + // codes into a smaller caller-facing reason set. + // + // Reference: + // https://www.postgresql.org/docs/current/errcodes-appendix.html + codeSerializationFailure = "40001" + codeDeadlockDetected = "40P01" + codeLockNotAvailable = "55P03" + codeQueryCanceled = "57014" + codeAdminShutdown = "57P01" + codeCrashShutdown = "57P02" + codeCannotConnectNow = "57P03" + codeTooManyConnections = "53300" + codeDiskFull = "53100" + codeOutOfMemory = "53200" + codeConfigLimitExceeded = "53400" + codeReadOnlyTxn = "25006" + codeInsufficientPriv = "42501" + codeCorruptData = "XX001" + codeCorruptIndex = "XX002" + codeUndefinedTable = "42P01" + codeUndefinedColumn = "42703" + codeUniqueViolation = "23505" + codeForeignKeyViolation = "23503" + codeCheckViolation = "23514" + codeNotNullViolation = "23502" + codeExclusionViolation = "23P01" +) + +// reasonByCode maps PostgreSQL SQLSTATE codes into the shared SQL error model. +var reasonByCode = map[string]dberr.Reason{ + codeSerializationFailure: dberr.ReasonSerialization, + codeDeadlockDetected: dberr.ReasonDeadlock, + codeLockNotAvailable: dberr.ReasonLocked, + codeQueryCanceled: dberr.ReasonUnknown, + codeAdminShutdown: dberr.ReasonUnavailable, + codeCrashShutdown: dberr.ReasonUnavailable, + codeCannotConnectNow: dberr.ReasonUnavailable, + codeTooManyConnections: dberr.ReasonPoolExhausted, + codeDiskFull: dberr.ReasonResourceExhausted, + codeOutOfMemory: dberr.ReasonResourceExhausted, + codeConfigLimitExceeded: dberr.ReasonResourceExhausted, + codeReadOnlyTxn: dberr.ReasonReadOnly, + codeInsufficientPriv: dberr.ReasonPermission, + codeCorruptData: dberr.ReasonCorrupt, + codeCorruptIndex: dberr.ReasonCorrupt, + codeUndefinedTable: dberr.ReasonSchemaMismatch, + codeUndefinedColumn: dberr.ReasonSchemaMismatch, + codeUniqueViolation: dberr.ReasonConstraint, + codeForeignKeyViolation: dberr.ReasonConstraint, + codeCheckViolation: dberr.ReasonConstraint, + codeNotNullViolation: dberr.ReasonConstraint, + codeExclusionViolation: dberr.ReasonConstraint, +} + +// mapErr maps PostgreSQL driver and transport errors into SQLError. +func mapErr(err error) *dberr.SQLError { + // Prefer SQLSTATE-based mapping first so a completed PostgreSQL statement + // keeps its specific backend code instead of being collapsed into a generic + // transport fallback. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return mapCode(pgErr.Code, err) + } + + var connectErr *pgconn.ConnectError + + // ConnectError means the driver failed while establishing or maintaining + // the connection, so callers see the same transient unavailable bucket used + // for other connection-path failures. + if errors.As(err, &connectErr) { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + } + + // Safe-to-retry and timeout failures happen on the connection or transport + // path rather than as a completed SQL statement outcome, so they share the + // transient backend-unavailable bucket with SQLSTATE connection exceptions. + if pgconn.SafeToRetry(err) || pgconn.Timeout(err) { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + } + + return nil +} + +// mapCode maps one PostgreSQL SQLSTATE into SQLError. +func mapCode(code string, err error) *dberr.SQLError { + reason, ok := reasonByCode[code] + if ok { + return dberr.NewSQLError(dberr.BackendPostgres, reason, code, err) + } + + if sqlStateClass(code) == connectionExceptionClass { + // SQLSTATE class 08 reports connection exceptions rather than completed + // statement outcomes, so it maps to the shared unavailable reason. + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, code, err, + ) + } + + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnknown, code, err, + ) +} + +// sqlStateClass returns the SQLSTATE class prefix. +func sqlStateClass(code string) string { + if len(code) < sqlStateClassLen { + return "" + } + + return code[:sqlStateClassLen] +} diff --git a/wallet/internal/db/pg/errors_test.go b/wallet/internal/db/pg/errors_test.go new file mode 100644 index 0000000000..81cc1c733a --- /dev/null +++ b/wallet/internal/db/pg/errors_test.go @@ -0,0 +1,118 @@ +package pg + +import ( + "io" + "testing" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// codeClassificationTestCase defines one SQLSTATE mapping expectation. +type codeClassificationTestCase struct { + // name describes the subtest. + name string + + // code is the PostgreSQL SQLSTATE under test. + code string + + // wantReason is the expected shared SQL error reason. + wantReason dberr.Reason +} + +// TestMapCode verifies that representative SQLSTATEs map to the expected +// reason and class buckets. +func TestMapCode(t *testing.T) { + t.Parallel() + + tests := []codeClassificationTestCase{ + { + name: "serialization", + code: codeSerializationFailure, + wantReason: dberr.ReasonSerialization, + }, + { + name: "deadlock", + code: codeDeadlockDetected, + wantReason: dberr.ReasonDeadlock, + }, + { + name: "too many connections", + code: codeTooManyConnections, + wantReason: dberr.ReasonPoolExhausted, + }, + { + name: "disk full", + code: codeDiskFull, + wantReason: dberr.ReasonResourceExhausted, + }, + { + name: "schema mismatch", + code: codeUndefinedTable, + wantReason: dberr.ReasonSchemaMismatch, + }, + { + name: "constraint", + code: codeUniqueViolation, + wantReason: dberr.ReasonConstraint, + }, + { + name: "not null constraint", + code: codeNotNullViolation, + wantReason: dberr.ReasonConstraint, + }, + { + name: "exclusion constraint", + code: codeExclusionViolation, + wantReason: dberr.ReasonConstraint, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := mapCode(testCase.code, io.EOF) + require.NotNil(t, err) + require.Equal(t, dberr.BackendPostgres, err.Backend) + require.Equal(t, testCase.wantReason, err.Reason) + require.Equal(t, testCase.wantReason.Class(), err.Class()) + require.Equal(t, testCase.code, err.Code) + }) + } +} + +// TestMapCodeFallback verifies the fallback mapping paths for connection +// exception classes and unknown SQLSTATEs. +func TestMapCodeFallback(t *testing.T) { + t.Parallel() + + connectionErr := mapCode("08006", io.EOF) + require.NotNil(t, connectionErr) + require.Equal(t, dberr.ReasonUnavailable, connectionErr.Reason) + require.Equal(t, dberr.ClassTransient, connectionErr.Class()) + + unknownErr := mapCode("99999", io.EOF) + require.NotNil(t, unknownErr) + require.Equal(t, dberr.ReasonUnknown, unknownErr.Reason) + require.Equal(t, dberr.ClassPermanent, unknownErr.Class()) + + require.Equal(t, "08", sqlStateClass("08006")) + require.Empty(t, sqlStateClass("0")) +} + +// TestMapErr verifies that driver-specific PostgreSQL errors are recognized and +// wrapped as SQL errors. +func TestMapErr(t *testing.T) { + t.Parallel() + + err := mapErr(&pgconn.PgError{ + Code: codeReadOnlyTxn, + Message: "read only", + }) + require.NotNil(t, err) + require.Equal(t, dberr.BackendPostgres, err.Backend) + require.Equal(t, dberr.ReasonReadOnly, err.Reason) + require.Equal(t, dberr.ClassFatal, err.Class()) +} From a3a35800f2f491faf64c4a4107f43d17e8a272e7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:56:03 +0800 Subject: [PATCH 240/695] db/sqlite: add SQL error mapper --- wallet/internal/db/sqlite/errors.go | 71 +++++++++++++++++++ wallet/internal/db/sqlite/errors_test.go | 89 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 wallet/internal/db/sqlite/errors.go create mode 100644 wallet/internal/db/sqlite/errors_test.go diff --git a/wallet/internal/db/sqlite/errors.go b/wallet/internal/db/sqlite/errors.go new file mode 100644 index 0000000000..90eda4e985 --- /dev/null +++ b/wallet/internal/db/sqlite/errors.go @@ -0,0 +1,71 @@ +package sqlite + +import ( + "errors" + "strconv" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +// SQLite result-code helper constants support error classification. +const ( + // primaryCodeMask strips a SQLite extended result code to its primary + // result code. + primaryCodeMask = 0xff +) + +// reasonByCode maps SQLite primary result codes into the shared SQL error +// model. +var reasonByCode = map[int]dberr.Reason{ + sqlite3.SQLITE_BUSY: dberr.ReasonBusy, + sqlite3.SQLITE_LOCKED: dberr.ReasonLocked, + sqlite3.SQLITE_INTERRUPT: dberr.ReasonUnknown, + sqlite3.SQLITE_CONSTRAINT: dberr.ReasonConstraint, + sqlite3.SQLITE_FULL: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_NOMEM: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_IOERR: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_PROTOCOL: dberr.ReasonUnavailable, + sqlite3.SQLITE_READONLY: dberr.ReasonReadOnly, + sqlite3.SQLITE_PERM: dberr.ReasonPermission, + sqlite3.SQLITE_CORRUPT: dberr.ReasonCorrupt, + sqlite3.SQLITE_NOTADB: dberr.ReasonCorrupt, + sqlite3.SQLITE_NOTFOUND: dberr.ReasonUnknown, + sqlite3.SQLITE_SCHEMA: dberr.ReasonSchemaMismatch, + sqlite3.SQLITE_CANTOPEN: dberr.ReasonUnknown, +} + +// mapErr maps SQLite result codes into SQLError. +func mapErr(err error) *dberr.SQLError { + // Start by extracting the SQLite driver error so the backend package can + // inspect its numeric result code. + var sqliteErr *sqlite.Error + if !errors.As(err, &sqliteErr) { + return nil + } + + code := sqliteErr.Code() + + // Reduce extended result codes to their primary code before consulting the + // shared reason map so related variants share one caller-facing bucket. + primaryCode := primaryCode(code) + codeString := codeString(code) + + reason, ok := reasonByCode[primaryCode] + if !ok { + reason = dberr.ReasonUnknown + } + + return dberr.NewSQLError(dberr.BackendSQLite, reason, codeString, err) +} + +// codeString formats a SQLite numeric result code for logs and stats. +func codeString(code int) string { + return strconv.Itoa(code) +} + +// primaryCode strips a SQLite extended result code to its primary code. +func primaryCode(code int) int { + return code & primaryCodeMask +} diff --git a/wallet/internal/db/sqlite/errors_test.go b/wallet/internal/db/sqlite/errors_test.go new file mode 100644 index 0000000000..fb6a4db9a0 --- /dev/null +++ b/wallet/internal/db/sqlite/errors_test.go @@ -0,0 +1,89 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "testing" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/stretchr/testify/require" + sqlite3 "modernc.org/sqlite/lib" +) + +// TestMapErrConstraint verifies that SQLite constraint violations are mapped to +// permanent constraint failures. +func TestMapErrConstraint(t *testing.T) { + t.Parallel() + + dbConn, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "wallet.db")) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT UNIQUE)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('dup')`) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('dup')`) + require.Error(t, err) + + sqlErr := mapErr(err) + require.NotNil(t, sqlErr) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.NotEmpty(t, sqlErr.Code) +} + +// TestMapErrReadOnly verifies that SQLite query-only failures are mapped to +// fatal read-only backend errors. +func TestMapErrReadOnly(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + + ctx := t.Context() + + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT)`, + ) + require.NoError(t, err) + require.NoError(t, dbConn.Close()) + + roDB, err := sql.Open("sqlite", dbPath+"?_pragma=query_only=on") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, roDB.Close()) + }) + + _, err = roDB.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('x')`) + require.Error(t, err) + + sqlErr := mapErr(err) + require.NotNil(t, sqlErr) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) + require.Equal(t, dberr.ReasonReadOnly, sqlErr.Reason) + require.Equal(t, dberr.ClassFatal, sqlErr.Class()) +} + +// TestHelpers verifies the SQLite-specific helper paths. +func TestHelpers(t *testing.T) { + t.Parallel() + + require.Equal(t, "5", codeString(5)) + require.Equal(t, dberr.ReasonUnavailable, + reasonByCode[sqlite3.SQLITE_PROTOCOL]) + require.Equal(t, dberr.ReasonUnknown, + reasonByCode[sqlite3.SQLITE_NOTFOUND]) + require.Equal(t, sqlite3.SQLITE_CONSTRAINT, + primaryCode(sqlite3.SQLITE_CONSTRAINT_UNIQUE)) +} From c5b8e350ef86450395f8205cd9b36a876122da9a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:44:28 +0800 Subject: [PATCH 241/695] db/err: add SQL error stats --- wallet/internal/db/err/stats.go | 226 +++++++++++++++++++++++++++ wallet/internal/db/err/stats_test.go | 115 ++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 wallet/internal/db/err/stats.go create mode 100644 wallet/internal/db/err/stats_test.go diff --git a/wallet/internal/db/err/stats.go b/wallet/internal/db/err/stats.go new file mode 100644 index 0000000000..60d4a2a1b3 --- /dev/null +++ b/wallet/internal/db/err/stats.go @@ -0,0 +1,226 @@ +package dberr + +import ( + "errors" + "sync/atomic" +) + +// StatsSnapshot is a read-only copy of the SQL backend error counters. +type StatsSnapshot struct { + // Backend identifies which SQL backend produced the snapshot. + Backend Backend + + // TotalErrs counts all classified SQL backend errors. + TotalErrs uint64 + + // TransientErrs counts classified transient SQL backend errors. + TransientErrs uint64 + + // PermanentErrs counts classified permanent SQL backend errors. + PermanentErrs uint64 + + // FatalErrs counts classified fatal SQL backend errors. + FatalErrs uint64 + + // Serialization counts serialization failures. + Serialization uint64 + + // Deadlocks counts deadlock failures. + Deadlocks uint64 + + // Busy counts SQLite busy failures. + Busy uint64 + + // Locked counts lock-not-available failures. + Locked uint64 + + // Unavailable counts backend availability failures. + Unavailable uint64 + + // PoolExhausted counts connection exhaustion failures. + PoolExhausted uint64 + + // ResourceExhausted counts disk, memory, or similar failures. + ResourceExhausted uint64 + + // ReadOnly counts read-only backend failures. + ReadOnly uint64 + + // Permission counts backend permission failures. + Permission uint64 + + // Corrupt counts corruption failures. + Corrupt uint64 + + // SchemaMismatch counts schema mismatch failures. + SchemaMismatch uint64 + + // Constraint counts backend constraint failures. + Constraint uint64 + + // Unknown counts classified backend failures with an unknown reason. + Unknown uint64 +} + +// Stats stores low-overhead atomic counters for classified SQL errors. +type Stats struct { + // totalErrs counts all classified SQL backend errors. + totalErrs atomic.Uint64 + + // transientErrs counts transient SQL backend errors. + transientErrs atomic.Uint64 + + // permanentErrs counts permanent SQL backend errors. + permanentErrs atomic.Uint64 + + // fatalErrs counts fatal SQL backend errors. + fatalErrs atomic.Uint64 + + // serialization counts serialization failures. + serialization atomic.Uint64 + + // deadlocks counts deadlock failures. + deadlocks atomic.Uint64 + + // busy counts SQLite busy failures. + busy atomic.Uint64 + + // locked counts lock-not-available failures. + locked atomic.Uint64 + + // unavailable counts backend availability failures. + unavailable atomic.Uint64 + + // poolExhausted counts connection exhaustion failures. + poolExhausted atomic.Uint64 + + // resourceExhausted counts resource exhaustion failures. + resourceExhausted atomic.Uint64 + + // readOnly counts read-only backend failures. + readOnly atomic.Uint64 + + // permission counts backend permission failures. + permission atomic.Uint64 + + // corrupt counts corruption failures. + corrupt atomic.Uint64 + + // schemaMismatch counts schema mismatch failures. + schemaMismatch atomic.Uint64 + + // constraint counts backend constraint failures. + constraint atomic.Uint64 + + // unknown counts classified backend failures with an unknown reason. + unknown atomic.Uint64 +} + +// reasonRecorder updates one per-reason counter on Stats. +type reasonRecorder func(*Stats) + +// reasonRecorders maps each valid reason to its per-reason counter update. +var reasonRecorders = [...]reasonRecorder{ + ReasonSerialization: func(s *Stats) { + s.serialization.Add(1) + }, + ReasonDeadlock: func(s *Stats) { + s.deadlocks.Add(1) + }, + ReasonBusy: func(s *Stats) { + s.busy.Add(1) + }, + ReasonLocked: func(s *Stats) { + s.locked.Add(1) + }, + ReasonUnavailable: func(s *Stats) { + s.unavailable.Add(1) + }, + ReasonPoolExhausted: func(s *Stats) { + s.poolExhausted.Add(1) + }, + ReasonSchemaMismatch: func(s *Stats) { + s.schemaMismatch.Add(1) + }, + ReasonConstraint: func(s *Stats) { + s.constraint.Add(1) + }, + ReasonUnknown: func(s *Stats) { + s.unknown.Add(1) + }, + ReasonResourceExhausted: func(s *Stats) { + s.resourceExhausted.Add(1) + }, + ReasonReadOnly: func(s *Stats) { + s.readOnly.Add(1) + }, + ReasonPermission: func(s *Stats) { + s.permission.Add(1) + }, + ReasonCorrupt: func(s *Stats) { + s.corrupt.Add(1) + }, +} + +// Record updates counters from a classified SQL backend error. +func (s *Stats) Record(err error) { + var sqlErr *SQLError + if !errors.As(err, &sqlErr) { + return + } + + s.totalErrs.Add(1) + + switch sqlErr.Class() { + case ClassTransient: + s.transientErrs.Add(1) + + case ClassPermanent: + s.permanentErrs.Add(1) + + case ClassFatal: + s.fatalErrs.Add(1) + } + + s.recordReason(sqlErr.Reason) +} + +// Snapshot returns a read-only copy of the current SQL error counters. +func (s *Stats) Snapshot(backend Backend) StatsSnapshot { + return StatsSnapshot{ + Backend: backend, + TotalErrs: s.totalErrs.Load(), + TransientErrs: s.transientErrs.Load(), + PermanentErrs: s.permanentErrs.Load(), + FatalErrs: s.fatalErrs.Load(), + Serialization: s.serialization.Load(), + Deadlocks: s.deadlocks.Load(), + Busy: s.busy.Load(), + Locked: s.locked.Load(), + Unavailable: s.unavailable.Load(), + PoolExhausted: s.poolExhausted.Load(), + ResourceExhausted: s.resourceExhausted.Load(), + ReadOnly: s.readOnly.Load(), + Permission: s.permission.Load(), + Corrupt: s.corrupt.Load(), + SchemaMismatch: s.schemaMismatch.Load(), + Constraint: s.constraint.Load(), + Unknown: s.unknown.Load(), + } +} + +// recordReason updates counters for one classified SQL error reason. +func (s *Stats) recordReason(reason Reason) { + if !reason.Valid() { + s.unknown.Add(1) + return + } + + record := reasonRecorders[reason] + if record == nil { + s.unknown.Add(1) + return + } + + record(s) +} diff --git a/wallet/internal/db/err/stats_test.go b/wallet/internal/db/err/stats_test.go new file mode 100644 index 0000000000..a7727514d7 --- /dev/null +++ b/wallet/internal/db/err/stats_test.go @@ -0,0 +1,115 @@ +package dberr + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + errTestSerialization = errors.New("serialization") + errTestConstraint = errors.New("constraint") + errTestPlain = errors.New("plain") +) + +// TestStatsRecord verifies that the stats collector updates class and reason +// counters from classified SQL backend errors. +func TestStatsRecord(t *testing.T) { + t.Parallel() + + var stats Stats + + stats.Record(NewSQLError( + BackendPostgres, + ReasonSerialization, + "40001", + errTestSerialization, + )) + stats.Record(NewSQLError( + BackendSQLite, + ReasonConstraint, + "2067", + errTestConstraint, + )) + + snapshot := stats.Snapshot(BackendPostgres) + require.EqualValues(t, 2, snapshot.TotalErrs) + require.EqualValues(t, 1, snapshot.TransientErrs) + require.EqualValues(t, 1, snapshot.PermanentErrs) + require.EqualValues(t, 1, snapshot.Serialization) + require.EqualValues(t, 1, snapshot.Constraint) +} + +// TestStatsRecordReasonAll verifies that each reason increments its expected +// counter. +func TestStatsRecordReasonAll(t *testing.T) { + t.Parallel() + + reasons := []Reason{ + ReasonSerialization, + ReasonDeadlock, + ReasonBusy, + ReasonLocked, + ReasonUnavailable, + ReasonPoolExhausted, + ReasonResourceExhausted, + ReasonReadOnly, + ReasonPermission, + ReasonCorrupt, + ReasonSchemaMismatch, + ReasonConstraint, + ReasonUnknown, + } + + var stats Stats + for _, reason := range reasons { + stats.recordReason(reason) + } + + snapshot := stats.Snapshot(BackendPostgres) + require.EqualValues(t, 1, snapshot.Serialization) + require.EqualValues(t, 1, snapshot.Deadlocks) + require.EqualValues(t, 1, snapshot.Busy) + require.EqualValues(t, 1, snapshot.Locked) + require.EqualValues(t, 1, snapshot.Unavailable) + require.EqualValues(t, 1, snapshot.PoolExhausted) + require.EqualValues(t, 1, snapshot.ResourceExhausted) + require.EqualValues(t, 1, snapshot.ReadOnly) + require.EqualValues(t, 1, snapshot.Permission) + require.EqualValues(t, 1, snapshot.Corrupt) + require.EqualValues(t, 1, snapshot.SchemaMismatch) + require.EqualValues(t, 1, snapshot.Constraint) + require.EqualValues(t, 1, snapshot.Unknown) +} + +// TestStatsRecordUnknown verifies that fallback reasons are counted as +// permanent unknown failures. +func TestStatsRecordUnknown(t *testing.T) { + t.Parallel() + + var stats Stats + stats.Record(NewSQLError(BackendSQLite, ReasonUnknown, "", errTestPlain)) + stats.recordReason(Reason(99)) + + snapshot := stats.Snapshot(BackendSQLite) + require.EqualValues(t, 1, snapshot.TotalErrs) + require.Zero(t, snapshot.TransientErrs) + require.EqualValues(t, 1, snapshot.PermanentErrs) + require.Zero(t, snapshot.FatalErrs) + require.EqualValues(t, 2, snapshot.Unknown) +} + +// TestStatsRecordIgnoresPlainErr verifies that only classified SQL backend +// errors affect the stats collector. +func TestStatsRecordIgnoresPlainErr(t *testing.T) { + t.Parallel() + + var stats Stats + + stats.Record(errTestPlain) + + snapshot := stats.Snapshot(BackendSQLite) + require.Zero(t, snapshot.TotalErrs) + require.Zero(t, snapshot.Unknown) +} From 044eecfd2a670036464fad5bd095733cc6e4103b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:44:53 +0800 Subject: [PATCH 242/695] db/err: add package README --- wallet/internal/db/err/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 wallet/internal/db/err/README.md diff --git a/wallet/internal/db/err/README.md b/wallet/internal/db/err/README.md new file mode 100644 index 0000000000..9d05b769ab --- /dev/null +++ b/wallet/internal/db/err/README.md @@ -0,0 +1,16 @@ +# `db/err` + +This package provides the shared SQL error taxonomy and normalization layer +used by the wallet database backends. + +It defines the common backend, class, and reason enums, the `SQLError` +wrapper used to preserve classification data in error chains, normalization +helpers for mapping backend failures into the shared model, and stats types for +recording classified SQL errors. + +Backend-specific mapping remains in the backend packages, including [`pg`](../pg/errors.go) +and [`sqlite`](../sqlite/errors.go). + +Higher-level runtime behavior such as retry handling, unhealthy-store +transitions, and transaction execution policy is implemented by callers outside +this package. From 88becf1ae74681b8567b9ba4d4445e823ecf4f3e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:59:11 +0800 Subject: [PATCH 243/695] db/runtime: add ambiguous tx commit error --- wallet/internal/db/runtime/runtime.go | 90 ++++++++++++++++++++++ wallet/internal/db/runtime/runtime_test.go | 60 +++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 wallet/internal/db/runtime/runtime.go create mode 100644 wallet/internal/db/runtime/runtime_test.go diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go new file mode 100644 index 0000000000..fc9397e2f1 --- /dev/null +++ b/wallet/internal/db/runtime/runtime.go @@ -0,0 +1,90 @@ +// Package runtime provides shared SQL execution helpers for split backend +// packages. +package runtime + +import ( + "database/sql" + "database/sql/driver" + "errors" + "io" +) + +// Runtime sentinel errors returned by the shared SQL helpers. +var ( + // ErrAmbiguousTxCommit matches commit failures whose final database outcome + // is unknown because the commit may already have reached the backend before + // the client observed the failure. + ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") +) + +// AmbiguousTxCommitError wraps a classified commit error returned when Commit +// fails after the final database outcome may already be decided by the +// backend. +// +// This lets callers ask two independent questions about the same failure: +// +// - errors.Is(err, ErrAmbiguousTxCommit) reports that the transaction outcome +// is unknown and should not be retried blindly. +// - errors.As / errors.Unwrap expose the classified backend error for +// logging, metrics, or backend-specific handling. +type AmbiguousTxCommitError struct { + // Err is the classified backend error observed during commit. + Err error +} + +// Error returns the wrapped error string. +func (e *AmbiguousTxCommitError) Error() string { + if e == nil || e.Err == nil { + return ErrAmbiguousTxCommit.Error() + } + + return e.Err.Error() +} + +// Unwrap returns the wrapped classified commit error. +func (e *AmbiguousTxCommitError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +// Is reports whether target matches the ambiguous commit sentinel. +func (e *AmbiguousTxCommitError) Is(target error) bool { + switch target { + case ErrAmbiguousTxCommit: + return true + + default: + return false + } +} + +// isCommitTransportError reports whether commit failed after the request may +// already have reached the backend. +func isCommitTransportError(err error) bool { + if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || + errors.Is(err, io.EOF) { + + return true + } + + return extractNetError(err) != nil +} + +// extractNetError extracts a net.Error used by commit transport checks. +func extractNetError(err error) netError { + var transportErr netError + if errors.As(err, &transportErr) { + return transportErr + } + + return nil +} + +// netError captures the net.Error behavior needed for commit transport checks. +type netError interface { + error + Timeout() bool +} diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go new file mode 100644 index 0000000000..f9d8334960 --- /dev/null +++ b/wallet/internal/db/runtime/runtime_test.go @@ -0,0 +1,60 @@ +package runtime + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test errors used by the runtime helper tests. +var errRuntimeOther = errors.New("other") + +// TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap +// behavior of AmbiguousTxCommitError. +func TestAmbiguousTxCommitError(t *testing.T) { + t.Parallel() + + err := &AmbiguousTxCommitError{Err: io.EOF} + wrappedSentinel := fmt.Errorf("wrap: %w", ErrAmbiguousTxCommit) + + require.ErrorIs(t, err, ErrAmbiguousTxCommit) + require.False(t, err.Is(wrappedSentinel)) + require.Equal(t, io.EOF.Error(), err.Error()) + require.ErrorIs(t, err, io.EOF) + require.Equal( + t, ErrAmbiguousTxCommit.Error(), (&AmbiguousTxCommitError{}).Error(), + ) + require.NoError(t, (*AmbiguousTxCommitError)(nil).Unwrap()) +} + +// TestCommitTransportError verifies that transport-shaped commit failures are +// detected as ambiguous-outcome candidates. +func TestCommitTransportError(t *testing.T) { + t.Parallel() + + require.True(t, isCommitTransportError(io.EOF)) + require.True(t, isCommitTransportError(driver.ErrBadConn)) + require.True(t, isCommitTransportError(sql.ErrConnDone)) + require.True(t, isCommitTransportError(netTimeoutError{})) + require.False(t, isCommitTransportError(errRuntimeOther)) + require.NotNil(t, extractNetError(netTimeoutError{})) + require.Nil(t, extractNetError(errRuntimeOther)) +} + +// netTimeoutError is a test helper that reports a transport timeout. +type netTimeoutError struct{} + +// Error returns the static timeout error string. +func (e netTimeoutError) Error() string { + return "timeout" +} + +// Timeout reports that the test error is a timeout. +func (e netTimeoutError) Timeout() bool { + return true +} From 6ab7678df30d6bae7b7f6f2fb525d100ad343e3e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:56:41 +0800 Subject: [PATCH 244/695] db/runtime: add Read helper --- wallet/internal/db/runtime/runtime.go | 285 ++++++++++++++++++ .../internal/db/runtime/runtime_bench_test.go | 64 ++++ wallet/internal/db/runtime/runtime_test.go | 260 +++++++++++++++- 3 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/runtime/runtime_bench_test.go diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index fc9397e2f1..248671c993 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -3,20 +3,37 @@ package runtime import ( + "context" "database/sql" "database/sql/driver" "errors" + "fmt" "io" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" ) // Runtime sentinel errors returned by the shared SQL helpers. var ( + // ErrStoreUnhealthy is returned when a runtime helper operates on an + // already unhealthy store. + ErrStoreUnhealthy = errors.New("sql store unhealthy") + // ErrAmbiguousTxCommit matches commit failures whose final database outcome // is unknown because the commit may already have reached the backend before // the client observed the failure. ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") ) +// Read config validation errors returned by Read. +var ( + errReadMaxAttempts = errors.New("read max attempts must be positive") + errReadBaseDelay = errors.New("read base delay must be positive") + errReadMaxDelay = errors.New("read max delay must be positive") + errReadDelayOrder = errors.New("read base delay exceeds max delay") +) + // AmbiguousTxCommitError wraps a classified commit error returned when Commit // fails after the final database outcome may already be decided by the // backend. @@ -61,6 +78,274 @@ func (e *AmbiguousTxCommitError) Is(target error) bool { } } +// ReadHooks defines the runtime hooks needed by the shared read helper. +type ReadHooks interface { + // CheckHealthy fails fast when the backend has already been marked + // unhealthy. + CheckHealthy() error + + // ClassifyError maps backend failures into the shared SQL error model. + ClassifyError(err error) error + + // RecordError records a classified SQL error. + RecordError(err error) + + // RecordRetryAttempt records one retry backoff attempt. + RecordRetryAttempt() + + // RecordRetrySuccess records a successful retry outcome. + RecordRetrySuccess() + + // RecordRetryExhausted records that retry budget was exhausted. + RecordRetryExhausted() +} + +// ReadConfig holds caller-provided retry settings for Read. +// +// Callers should derive this from backend or wallet configuration so retry +// policy stays outside the shared runtime helper package. +type ReadConfig struct { + // MaxAttempts is the maximum number of callback attempts, including the + // first attempt. Set this to 1 to disable retries. + MaxAttempts int + + // BaseDelay is the starting backoff delay before jitter is applied. + // Ignored when MaxAttempts is 1. + BaseDelay time.Duration + + // MaxDelay is the upper bound for the exponential backoff delay. + // Ignored when MaxAttempts is 1. + MaxDelay time.Duration +} + +// readConfig holds the retry settings used by readWithConfig. +type readConfig struct { + // attempts is the maximum number of callback attempts. + attempts int + + // base is the starting backoff delay before jitter is applied. + base time.Duration + + // max is the upper bound for the exponential backoff delay. + max time.Duration + + // jitter rewrites one calculated delay before waiting. + jitter func(time.Duration) time.Duration + + // timer waits for one retry delay. + timer func(time.Duration) *time.Timer +} + +// Read executes a read-only SQL callback with transient retry handling. +// +// Read returns the callback result from the first successful attempt. On any +// failure, it returns the zero value of T together with the final error. +func Read[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, + config ReadConfig, + fn func(context.Context, Q) (T, error)) (T, error) { + + execConfig, err := buildReadConfig(config) + if err != nil { + var zero T + + return zero, fmt.Errorf("build read config: %w", err) + } + + return readWithConfig(ctx, hooks, queries, fn, execConfig) +} + +// buildReadConfig converts the caller-facing config into runtime settings. +func buildReadConfig(config ReadConfig) (readConfig, error) { + switch { + case config.MaxAttempts <= 0: + return readConfig{}, errReadMaxAttempts + + case config.MaxAttempts > 1 && config.BaseDelay <= 0: + return readConfig{}, errReadBaseDelay + + case config.MaxAttempts > 1 && config.MaxDelay <= 0: + return readConfig{}, errReadMaxDelay + + case config.MaxAttempts > 1 && config.BaseDelay > config.MaxDelay: + return readConfig{}, errReadDelayOrder + } + + return readConfig{ + attempts: config.MaxAttempts, + base: config.BaseDelay, + max: config.MaxDelay, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + timer: time.NewTimer, + }, nil +} + +// readWithConfig executes Read with injected retry settings. +func readWithConfig[Q any, T any](ctx context.Context, hooks ReadHooks, + queries Q, fn func(context.Context, Q) (T, error), + config readConfig) (T, error) { + + var zero T + + // Fail fast if a prior fatal backend error already poisoned the store. + err := hooks.CheckHealthy() + if err != nil { + return zero, fmt.Errorf("check store health: %w", err) + } + + for attempt := range config.attempts { + result, shouldRetry, err := readAttempt(ctx, hooks, queries, fn) + if !shouldRetry && err == nil { + if attempt > 0 { + hooks.RecordRetrySuccess() + } + + return result, nil + } + + if !shouldRetry { + return zero, err + } + + if attempt == config.attempts-1 { + hooks.RecordRetryExhausted() + return zero, fmt.Errorf("read: %w", err) + } + + hooks.RecordRetryAttempt() + + // Use exponential backoff with injectable jitter and timer hooks so the + // helper stays deterministic under test. + delay := retryDelay(attempt, config.base, config.max) + delay = config.jitter(delay) + + err = waitForRetry(ctx, config.timer, delay) + if err != nil { + return zero, err + } + } + + return zero, ErrStoreUnhealthy +} + +// readAttempt executes one read callback attempt. +// +// It returns the callback result on success. On failure, it returns the zero +// value of T together with either an immediate return error or a classified +// transient error for the outer retry loop. +func readAttempt[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, + fn func(context.Context, Q) (T, error)) (T, bool, error) { + + var zero T + + // Run the read callback first so successful reads never pay any + // classification or retry overhead. + result, err := fn(ctx, queries) + if err == nil { + return result, false, nil + } + + // Preserve caller-driven cancellation and no-row results unchanged. + ctxErr := unwrapContextError(err) + if ctxErr != nil { + return zero, false, ctxErr + } + + if errors.Is(err, sql.ErrNoRows) { + return zero, false, err + } + + // Classify the backend failure once before deciding whether the read is + // safe to retry. + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + // Retry only transient classified failures. Everything else returns to the + // caller immediately with context. + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) || + sqlErr.Class() != dberr.ClassTransient { + + return zero, false, fmt.Errorf("read: %w", classifiedErr) + } + + //nolint:wrapcheck + // We need to return the exact classified error to be wrapped by Read. + return zero, true, classifiedErr +} + +// retryDelay applies bounded exponential backoff for retry attempts. +func retryDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration { + // The first retry waits for the configured base delay. Each later retry + // doubles the previous delay until the backoff reaches the configured cap. + delay := baseDelay + + for range attempt { + // Saturate before doubling once the next step would reach or exceed the + // cap. The maxDelay/2 check avoids computing delay*2 when the bounded + // result must already be maxDelay. + if delay >= maxDelay || delay > maxDelay/2 { + return maxDelay + } + + delay *= 2 + } + + // Keep the helper defensive when called directly with unchecked values. + if delay > maxDelay { + return maxDelay + } + + return delay +} + +// waitForRetry waits for the next retry delay or returns ctx.Err when the +// caller cancels first. +func waitForRetry(ctx context.Context, + timer func(time.Duration) *time.Timer, delay time.Duration) error { + + retryTimer := timer(delay) + defer stopRetryTimer(retryTimer) + + select { + case <-retryTimer.C: + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} + +// stopRetryTimer stops a retry timer and drains a fired value when needed. +func stopRetryTimer(timer *time.Timer) { + if timer == nil { + return + } + + if timer.Stop() { + return + } + + select { + case <-timer.C: + default: + } +} + +// unwrapContextError preserves caller-driven cancellation and deadlines. +func unwrapContextError(err error) error { + if errors.Is(err, context.Canceled) { + return err + } + + if errors.Is(err, context.DeadlineExceeded) { + return err + } + + return nil +} + // isCommitTransportError reports whether commit failed after the request may // already have reached the backend. func isCommitTransportError(err error) bool { diff --git a/wallet/internal/db/runtime/runtime_bench_test.go b/wallet/internal/db/runtime/runtime_bench_test.go new file mode 100644 index 0000000000..7da3ee474e --- /dev/null +++ b/wallet/internal/db/runtime/runtime_bench_test.go @@ -0,0 +1,64 @@ +package runtime + +import ( + "context" + "testing" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" +) + +// BenchmarkReadSuccess measures a successful read with no retries. +func BenchmarkReadSuccess(b *testing.B) { + hooks := &fakeStore{} + for range b.N { + _, _ = readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }, + readConfig{ + attempts: 1, + base: time.Millisecond, + max: time.Millisecond, + timer: time.NewTimer, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + }, + ) + } +} + +// BenchmarkReadTransientRetry measures one retry before success. +func BenchmarkReadTransientRetry(b *testing.B) { + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + for range b.N { + attempts := 0 + _, _ = readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (struct{}, error) { + attempts++ + if attempts == 1 { + return struct{}{}, errRuntimeBusy + } + + return struct{}{}, nil + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + timer: immediateTimer, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + }, + ) + } +} diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go index f9d8334960..29743fe956 100644 --- a/wallet/internal/db/runtime/runtime_test.go +++ b/wallet/internal/db/runtime/runtime_test.go @@ -1,18 +1,70 @@ package runtime import ( + "context" "database/sql" "database/sql/driver" "errors" "fmt" "io" "testing" + "time" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/stretchr/testify/require" ) // Test errors used by the runtime helper tests. -var errRuntimeOther = errors.New("other") +var ( + errRuntimeBusy = errors.New("busy") + errRuntimeRetry = errors.New("retry") + errRuntimeOther = errors.New("other") +) + +// fakeStore implements the runtime hook interfaces for tests. +type fakeStore struct { + healthyErr error + classifyFn func(error) error + errorCount int + retryAttempts int + retrySuccesses int + retryExhausted int + ambiguousCommit int +} + +// CheckHealthy returns the configured health-check result for fakeStore. +func (s *fakeStore) CheckHealthy() error { + return s.healthyErr +} + +// ClassifyError applies the configured classifier when one is present. +func (s *fakeStore) ClassifyError(err error) error { + if s.classifyFn != nil { + return s.classifyFn(err) + } + + return err +} + +// RecordError increments the fake classified-error counter. +func (s *fakeStore) RecordError(error) { + s.errorCount++ +} + +// RecordRetryAttempt increments the fake retry-attempt counter. +func (s *fakeStore) RecordRetryAttempt() { + s.retryAttempts++ +} + +// RecordRetrySuccess increments the fake retry-success counter. +func (s *fakeStore) RecordRetrySuccess() { + s.retrySuccesses++ +} + +// RecordRetryExhausted increments the fake retry-exhausted counter. +func (s *fakeStore) RecordRetryExhausted() { + s.retryExhausted++ +} // TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap // behavior of AmbiguousTxCommitError. @@ -46,6 +98,212 @@ func TestCommitTransportError(t *testing.T) { require.Nil(t, extractNetError(errRuntimeOther)) } +// TestReadHealthyCheck verifies that unhealthy stores fail fast. +func TestReadHealthyCheck(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{healthyErr: ErrStoreUnhealthy} + _, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }) + require.ErrorIs(t, err, ErrStoreUnhealthy) +} + +// TestReadInvalidConfig verifies that invalid retry settings fail fast. +func TestReadInvalidConfig(t *testing.T) { + t.Parallel() + + _, err := Read(context.Background(), &fakeStore{}, struct{}{}, + ReadConfig{}, func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }) + require.EqualError(t, err, + "build read config: read max attempts must be positive") +} + +// TestReadReturnsValue verifies that successful reads return their value and do +// not force disabled retries to provide unused backoff delays. +func TestReadReturnsValue(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + }, + func(context.Context, struct{}) (string, error) { + return "ok", nil + }) + require.NoError(t, err) + require.Equal(t, "ok", result) +} + +// TestReadNoRowsPassthrough verifies that no-row results are preserved. +func TestReadNoRowsPassthrough(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (string, error) { + return "", sql.ErrNoRows + }) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, result) + require.Zero(t, hooks.errorCount) +} + +// TestReadRetriesTransientError verifies that transient read failures retry, +// record stats, and eventually succeed. +func TestReadRetriesTransientError(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + attempts := 0 + result, err := readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + attempts++ + if attempts == 1 { + return "", errRuntimeBusy + } + + return "ok", nil + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: immediateTimer, + }, + ) + require.NoError(t, err) + require.Equal(t, "ok", result) + require.Equal(t, 1, hooks.retryAttempts) + require.Equal(t, 1, hooks.retrySuccesses) + require.Equal(t, 1, hooks.errorCount) +} + +// TestReadRetryExhausted verifies that transient reads return the final +// classified error and a zero value after their retry budget is exhausted. +func TestReadRetryExhausted(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonSerialization, + "40001", err, + ) + }} + + result, err := readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + return "", errRuntimeRetry + }, + readConfig{ + attempts: 1, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: immediateTimer, + }, + ) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ClassTransient, sqlErr.Class()) + require.Equal(t, 1, hooks.retryExhausted) +} + +// TestReadWrappedContextCancellation verifies that wrapped caller cancellation +// is not hidden behind retry logic. +func TestReadWrappedContextCancellation(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + wrappedCanceled := fmt.Errorf("read accounts: %w", context.Canceled) + + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (string, error) { + return "", wrappedCanceled + }) + require.Empty(t, result) + require.Same(t, wrappedCanceled, err) + require.ErrorIs(t, err, context.Canceled) +} + +// TestReadWaitCancellation verifies that cancellation during backoff returns +// ctx.Err. +func TestReadWaitCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + result, err := readWithConfig( + ctx, hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + return "", errRuntimeBusy + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: time.NewTimer, + }, + ) + require.Empty(t, result) + require.ErrorIs(t, err, context.Canceled) +} + +// TestReadUtilities verifies the remaining read helper branches. +func TestReadUtilities(t *testing.T) { + t.Parallel() + + require.Equal(t, 100*time.Millisecond, + retryDelay(10, 10*time.Millisecond, 100*time.Millisecond)) + require.Equal(t, 100*time.Millisecond, + retryDelay(63, 10*time.Millisecond, 100*time.Millisecond)) + require.Equal(t, context.DeadlineExceeded, + unwrapContextError(context.DeadlineExceeded)) + + wrappedCanceled := fmt.Errorf("read: %w", context.Canceled) + require.Same(t, wrappedCanceled, unwrapContextError(wrappedCanceled)) + require.NoError(t, unwrapContextError(sql.ErrNoRows)) +} + +// immediateTimer returns a timer that fires immediately. +func immediateTimer(time.Duration) *time.Timer { + return time.NewTimer(0) +} + // netTimeoutError is a test helper that reports a transport timeout. type netTimeoutError struct{} From 7ac9a44670b205f09a377a35b50066c87906db6f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:51:22 +0800 Subject: [PATCH 245/695] db/runtime: add Write helper --- wallet/internal/db/runtime/runtime.go | 129 ++++++++- wallet/internal/db/runtime/runtime_test.go | 295 +++++++++++++++++---- 2 files changed, 376 insertions(+), 48 deletions(-) diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index 248671c993..1c1b83300e 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -23,6 +23,12 @@ var ( // ErrAmbiguousTxCommit matches commit failures whose final database outcome // is unknown because the commit may already have reached the backend before // the client observed the failure. + // + // Callers should treat this differently from an ordinary commit failure and + // should not blindly retry the transaction. Write returns this through + // AmbiguousTxCommitError so callers can use errors.Is(err, + // ErrAmbiguousTxCommit) for policy checks while still inspecting the + // underlying classified backend error. ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") ) @@ -34,8 +40,8 @@ var ( errReadDelayOrder = errors.New("read base delay exceeds max delay") ) -// AmbiguousTxCommitError wraps a classified commit error returned when Commit -// fails after the final database outcome may already be decided by the +// AmbiguousTxCommitError wraps a classified commit error returned by Write when +// Commit fails after the final database outcome may already be decided by the // backend. // // This lets callers ask two independent questions about the same failure: @@ -100,6 +106,25 @@ type ReadHooks interface { RecordRetryExhausted() } +// WriteHooks defines the runtime hooks needed by the shared transaction helper. +type WriteHooks interface { + // CheckHealthy fails fast when the backend has already been marked + // unhealthy. + CheckHealthy() error + + // ClassifyError maps backend failures into the shared SQL error model. + ClassifyError(err error) error + + // RecordError records a classified SQL error. + RecordError(err error) + + // RecordAmbiguousTxCommit records a commit failure with unknown outcome. + RecordAmbiguousTxCommit() + + // RawDB returns the backend database handle used for transactions. + RawDB() *sql.DB +} + // ReadConfig holds caller-provided retry settings for Read. // // Callers should derive this from backend or wallet configuration so retry @@ -275,6 +300,106 @@ func readAttempt[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, return zero, true, classifiedErr } +// Write executes a transactional SQL callback without retrying it. +// +// Write returns the callback result only after Commit succeeds. If Begin, +// callback execution, or Commit fails, it returns the zero value of T together +// with the resulting error. +// +// hooks supplies the shared health check, error classification, SQL error +// recording, ambiguous-commit accounting, and raw database handle used by the +// helper. +// +// bind converts the started *sql.Tx into the caller's transactional query +// handle, such as a sqlc Queries value bound to that transaction. +// +// fn performs the transactional work with that bound handle. SQL and backend +// callback failures are normalized through hooks.ClassifyError, while ordinary +// domain errors pass through unchanged. +// +// If Commit fails after the backend may already have applied the transaction, +// Write returns an AmbiguousTxCommitError. Callers should detect that case +// with errors.Is(err, ErrAmbiguousTxCommit) before deciding whether retrying +// or compensating work is safe. +func Write[Q any, T any](ctx context.Context, hooks WriteHooks, + bind func(*sql.Tx) Q, fn func(Q) (T, error)) (T, error) { + + var zero T + + // Fail fast if a prior fatal backend error already poisoned the store. + err := hooks.CheckHealthy() + if err != nil { + return zero, fmt.Errorf("check store health: %w", err) + } + + // Begin the transaction before invoking the callback so begin failures are + // still classified and recorded consistently. + tx, err := hooks.RawDB().BeginTx(ctx, nil) + if err != nil { + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + return zero, fmt.Errorf("begin tx: %w", classifiedErr) + } + + defer func() { + _ = tx.Rollback() + }() + + // Callback errors are normalized through the backend classifier so SQL + // driver failures reach callers consistently while non-SQL domain errors + // pass through unchanged. + result, err := fn(bind(tx)) + if err != nil { + classifiedErr := hooks.ClassifyError(err) + recordWriteCallbackError(hooks, classifiedErr) + + return zero, normalizeWriteCallbackError(err, classifiedErr) + } + + // Commit transport failures are wrapped so callers know the final + // transaction outcome is unknown. + err = tx.Commit() + if err != nil { + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + if isCommitTransportError(err) { + hooks.RecordAmbiguousTxCommit() + + return zero, &AmbiguousTxCommitError{ + Err: fmt.Errorf("commit tx: %w", classifiedErr), + } + } + + return zero, fmt.Errorf("commit tx: %w", classifiedErr) + } + + return result, nil +} + +// normalizeWriteCallbackError preserves unchanged callback errors while +// returning normalized SQL backend failures when classification added one. +func normalizeWriteCallbackError(err, classifiedErr error) error { + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) { + return err + } + + return classifiedErr +} + +// recordWriteCallbackError records one classified SQL callback error while +// leaving non-SQL callback failures out of SQL error accounting. +func recordWriteCallbackError(hooks WriteHooks, classifiedErr error) { + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) { + return + } + + hooks.RecordError(classifiedErr) +} + // retryDelay applies bounded exponential backoff for retry attempts. func retryDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration { // The first retry waits for the configured base delay. Each later retry diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go index 29743fe956..d1ecf5e06e 100644 --- a/wallet/internal/db/runtime/runtime_test.go +++ b/wallet/internal/db/runtime/runtime_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "sync/atomic" "testing" "time" @@ -16,20 +17,23 @@ import ( // Test errors used by the runtime helper tests. var ( - errRuntimeBusy = errors.New("busy") - errRuntimeRetry = errors.New("retry") - errRuntimeOther = errors.New("other") + errRuntimeBusy = errors.New("busy") + errRuntimeRetry = errors.New("retry") + errRuntimeCallback = errors.New("callback failed") + errRuntimeOther = errors.New("other") + errRuntimeUnused = errors.New("unused") ) // fakeStore implements the runtime hook interfaces for tests. type fakeStore struct { - healthyErr error - classifyFn func(error) error - errorCount int - retryAttempts int - retrySuccesses int - retryExhausted int - ambiguousCommit int + healthyErr error + classifyFn func(error) error + errorCount int + retryAttempts int + retrySuccesses int + retryExhausted int + ambiguousCommits int + db *sql.DB } // CheckHealthy returns the configured health-check result for fakeStore. @@ -66,36 +70,14 @@ func (s *fakeStore) RecordRetryExhausted() { s.retryExhausted++ } -// TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap -// behavior of AmbiguousTxCommitError. -func TestAmbiguousTxCommitError(t *testing.T) { - t.Parallel() - - err := &AmbiguousTxCommitError{Err: io.EOF} - wrappedSentinel := fmt.Errorf("wrap: %w", ErrAmbiguousTxCommit) - - require.ErrorIs(t, err, ErrAmbiguousTxCommit) - require.False(t, err.Is(wrappedSentinel)) - require.Equal(t, io.EOF.Error(), err.Error()) - require.ErrorIs(t, err, io.EOF) - require.Equal( - t, ErrAmbiguousTxCommit.Error(), (&AmbiguousTxCommitError{}).Error(), - ) - require.NoError(t, (*AmbiguousTxCommitError)(nil).Unwrap()) +// RecordAmbiguousTxCommit increments the fake ambiguous-commit counter. +func (s *fakeStore) RecordAmbiguousTxCommit() { + s.ambiguousCommits++ } -// TestCommitTransportError verifies that transport-shaped commit failures are -// detected as ambiguous-outcome candidates. -func TestCommitTransportError(t *testing.T) { - t.Parallel() - - require.True(t, isCommitTransportError(io.EOF)) - require.True(t, isCommitTransportError(driver.ErrBadConn)) - require.True(t, isCommitTransportError(sql.ErrConnDone)) - require.True(t, isCommitTransportError(netTimeoutError{})) - require.False(t, isCommitTransportError(errRuntimeOther)) - require.NotNil(t, extractNetError(netTimeoutError{})) - require.Nil(t, extractNetError(errRuntimeOther)) +// RawDB returns the fake database handle used by transaction tests. +func (s *fakeStore) RawDB() *sql.DB { + return s.db } // TestReadHealthyCheck verifies that unhealthy stores fail fast. @@ -299,20 +281,241 @@ func TestReadUtilities(t *testing.T) { require.NoError(t, unwrapContextError(sql.ErrNoRows)) } +// TestWriteHealthyCheck verifies that unhealthy stores fail fast before +// starting a transaction. +func TestWriteHealthyCheck(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{healthyErr: ErrStoreUnhealthy} + result, err := Write(context.Background(), hooks, func(*sql.Tx) struct{} { + return struct{}{} + }, func(struct{}) (string, error) { return "", nil }) + require.Empty(t, result) + require.ErrorIs(t, err, ErrStoreUnhealthy) +} + +// TestWriteReturnsValue verifies that successful writes return their value only +// after commit succeeds. +func TestWriteReturnsValue(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "ok", nil + }) + require.NoError(t, err) + require.Equal(t, "ok", result) +} + +// TestWriteBeginFailure verifies that begin failures are classified and +// recorded. +func TestWriteBeginFailure(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{err: driver.ErrBadConn}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(*sql.Tx) struct{} { + return struct{}{} + }, func(struct{}) (string, error) { return "", nil }) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCallbackErrorPassthrough verifies that non-SQL callback errors pass +// through unchanged and do not leak a result value. +func TestWriteCallbackErrorPassthrough(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn} + callbackErr := errRuntimeCallback + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "", callbackErr + }) + require.Empty(t, result) + require.ErrorIs(t, err, callbackErr) + require.Zero(t, hooks.errorCount) +} + +// TestWriteCallbackErrorClassified verifies that SQL-like callback failures are +// normalized through the backend classifier before they reach callers. +func TestWriteCallbackErrorClassified(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonConstraint, "23505", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "", errRuntimeCallback + }) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCommitAmbiguous verifies that transport failures during commit are +// wrapped as ambiguous commit errors, recorded, and return a zero value. +func TestWriteCommitAmbiguous(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{commitErr: io.EOF}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, + "08006", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "applied", nil + }) + require.Empty(t, result) + require.ErrorIs(t, err, ErrAmbiguousTxCommit) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Equal(t, 1, hooks.ambiguousCommits) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCommitUnavailable verifies that classified availability errors +// without a transport failure stay ordinary commit errors. +func TestWriteCommitUnavailable(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{commitErr: errRuntimeOther}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, + "08006", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "applied", nil + }) + require.Empty(t, result) + require.NotErrorIs(t, err, ErrAmbiguousTxCommit) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Zero(t, hooks.ambiguousCommits) + require.Equal(t, 1, hooks.errorCount) +} + // immediateTimer returns a timer that fires immediately. func immediateTimer(time.Duration) *time.Timer { return time.NewTimer(0) } -// netTimeoutError is a test helper that reports a transport timeout. -type netTimeoutError struct{} +// beginErrDriver is a test SQL driver that injects begin and commit failures. +type beginErrDriver struct { + err error + commitErr error +} + +// Open returns a connection that injects the configured failures. +func (d beginErrDriver) Open(string) (driver.Conn, error) { + return beginErrConn(d), nil +} + +// beginErrConn is a test connection that injects begin and commit failures. +type beginErrConn struct { + err error + commitErr error +} + +// Prepare returns an unused statement error for the test driver. +func (c beginErrConn) Prepare(string) (driver.Stmt, error) { + return nil, errRuntimeUnused +} + +// Close reports success for the test connection close path. +func (c beginErrConn) Close() error { + return nil +} -// Error returns the static timeout error string. -func (e netTimeoutError) Error() string { - return "timeout" +// Begin returns the legacy unused error because tests use BeginTx instead. +func (c beginErrConn) Begin() (driver.Tx, error) { + return nil, errRuntimeUnused } -// Timeout reports that the test error is a timeout. -func (e netTimeoutError) Timeout() bool { - return true +// BeginTx returns the configured begin error or a transaction test double. +func (c beginErrConn) BeginTx(ctx context.Context, + _ driver.TxOptions) (driver.Tx, error) { + + _ = ctx + + if c.err != nil { + return nil, c.err + } + + return beginErrTx{commitErr: c.commitErr}, nil +} + +// beginErrTx is a test transaction that injects a commit failure. +type beginErrTx struct { + commitErr error +} + +// Commit returns the configured commit error. +func (tx beginErrTx) Commit() error { + return tx.commitErr +} + +// Rollback reports success for the test rollback path. +func (tx beginErrTx) Rollback() error { + return nil +} + +// testDriverSeq keeps each registered test driver name unique. +var testDriverSeq atomic.Uint64 + +// newTestDB registers one test driver instance and opens a matching database. +func newTestDB(t *testing.T, drv beginErrDriver) *sql.DB { + t.Helper() + + name := fmt.Sprintf("runtime-test-%d", testDriverSeq.Add(1)) + sql.Register(name, drv) + + dbConn, err := sql.Open(name, "") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + return dbConn } From 37ff66752d05a67d80331c85c1e9b9997fc7c038 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Apr 2026 18:33:57 +0800 Subject: [PATCH 246/695] bwtest: pin bitcoind to v1 transport bitcoind v30 enables v2 transport by default, while the shared btcd\nminer in the integration harness still runs in legacy transport mode.\nThis can leave the bitcoind backend stuck at height 0 during startup.\n\nPin bitcoind to v1 in the harness so the backend connects\ndeterministically during CI and local bitcoind itests. --- bwtest/bitcoind.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bwtest/bitcoind.go b/bwtest/bitcoind.go index b949df838a..b8c62c2c48 100644 --- a/bwtest/bitcoind.go +++ b/bwtest/bitcoind.go @@ -154,6 +154,12 @@ func (b *BitcoindBackend) Start() error { "-regtest", "-connect=" + b.minerAddr, + // bitcoind enables P2P v2 by default, but the shared btcd miner keeps + // v2 transport disabled by default. Pin bitcoind to v1 here so harness + // startup does not rely on downgrade timing during the first peer + // handshake. + "-v2transport=0", + // Enable wallet-required indexing and RPC auth. "-txindex", "-disablewallet", From 72e26ee91c0aa6ccf869e7b834155b5c8f73a84b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:35:34 -0300 Subject: [PATCH 247/695] wallet: add key scope wallet/id index --- wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql | 3 +++ wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql | 3 +++ 2 files changed, 6 insertions(+) diff --git a/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql index 5386d22855..7275240b4a 100644 --- a/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql +++ b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql @@ -41,6 +41,9 @@ CREATE TABLE key_scopes ( CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin ON key_scopes (wallet_id, purpose, coin_type); +-- Unique index to support composite foreign keys scoped by wallet ownership. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_id_id ON key_scopes (wallet_id, id); + -- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. -- Separated from the main key_scopes table for security and access pattern isolation. -- Watch-only scopes may have no corresponding row in this table or have NULL diff --git a/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql index ade3e3ef78..1f78f64f20 100644 --- a/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql @@ -41,6 +41,9 @@ CREATE TABLE key_scopes ( CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin ON key_scopes (wallet_id, purpose, coin_type); +-- Unique index to support composite foreign keys scoped by wallet ownership. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_id_id ON key_scopes (wallet_id, id); + -- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. -- Separated from the main key_scopes table for security and access pattern isolation. -- Watch-only scopes may have no corresponding row in this table or have NULL From 7929cd62a72315e5cc89d1a9e0784079e76999d8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:04 -0300 Subject: [PATCH 248/695] wallet: scope accounts to wallet ownership --- .../internal/db/itest/account_store_test.go | 46 ++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 54 +++++++++++++- .../internal/db/itest/fixtures_sqlite_test.go | 54 +++++++++++++- .../sql/pg/migrations/000005_accounts.up.sql | 16 ++++- wallet/internal/sql/pg/queries/accounts.sql | 63 +++++++++++----- wallet/internal/sql/pg/sqlc/accounts.sql.go | 71 +++++++++++++------ wallet/internal/sql/pg/sqlc/models.go | 1 + .../sqlite/migrations/000005_accounts.up.sql | 16 ++++- .../internal/sql/sqlite/queries/accounts.sql | 61 +++++++++++----- .../internal/sql/sqlite/sqlc/accounts.sql.go | 69 ++++++++++++------ wallet/internal/sql/sqlite/sqlc/models.go | 1 + 11 files changed, 366 insertions(+), 86 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 63ee0e195f..db06fb306a 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -46,6 +46,52 @@ func TestCreateAccounts(t *testing.T) { } } +// TestCreateDerivedAccountRejectsWalletScopeMismatch verifies that the +// composite wallet/scope invariant is enforced by the database on direct +// derived-account inserts. +func TestCreateDerivedAccountRejectsWalletScopeMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-derived-account-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-derived-account-mismatch-b") + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "seed-derived-scope", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + + err := createDerivedAccountRaw( + t, store.DB(), secondWalletID, firstScopeID, 0, "raw-derived-mismatch", + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateImportedAccountRejectsWalletScopeMismatch verifies that the +// composite wallet/scope invariant is enforced by the database on direct +// imported-account inserts. +func TestCreateImportedAccountRejectsWalletScopeMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-imported-account-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-imported-account-mismatch-b") + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "seed-imported-scope", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + + err := createImportedAccountRaw( + t, store.DB(), secondWalletID, firstScopeID, "raw-imported-mismatch", + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + // TestCreateDerivedAccountErrors verifies that CreateDerivedAccount returns // appropriate errors for invalid inputs. func TestCreateDerivedAccountErrors(t *testing.T) { diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index c029000305..8633a4f827 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -51,15 +51,67 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, require.NoError(t, err) } +// createDerivedAccountRaw inserts a derived account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createDerivedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, accountNumber uint32, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + is_watch_only + ) VALUES ($1, $2, $3, $4, $5, $6)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, int64(accountNumber), + name, int16(db.DerivedAccount), false, + ) + + return err +} + +// createImportedAccountRaw inserts an imported account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createImportedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + is_watch_only + ) VALUES ($1, $2, NULL, $3, $4, $5, $6)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, name, + int16(db.ImportedAccount), RandomBytes(32), true, + ) + + return err +} + // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, - accountID int64, branch int16, index uint32) { + walletID uint32, accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index d2f2475e72..9ba8b38302 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -51,15 +51,67 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, require.NoError(t, err) } +// createDerivedAccountRaw inserts a derived account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createDerivedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, accountNumber uint32, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + is_watch_only + ) VALUES (?, ?, ?, ?, ?, ?)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, int64(accountNumber), + name, int64(db.DerivedAccount), false, + ) + + return err +} + +// createImportedAccountRaw inserts an imported account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createImportedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + is_watch_only + ) VALUES (?, ?, NULL, ?, ?, ?, ?)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, name, + int64(db.ImportedAccount), RandomBytes(32), true, + ) + + return err +} + // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, - accountID int64, branch uint32, index uint32) { + walletID uint32, accountID int64, branch uint32, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int64(db.WitnessPubKey), diff --git a/wallet/internal/sql/pg/migrations/000005_accounts.up.sql b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql index cea4682d93..a1b1914c23 100644 --- a/wallet/internal/sql/pg/migrations/000005_accounts.up.sql +++ b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql @@ -38,6 +38,9 @@ CREATE TABLE accounts ( -- DB ID of the account, primary key. id BIGSERIAL PRIMARY KEY, + -- Reference to the wallet this account belongs to. + wallet_id BIGINT NOT NULL, + -- Reference to the key scope this account belongs to. scope_id BIGINT NOT NULL, @@ -83,9 +86,13 @@ CREATE TABLE accounts ( -- Imported address counter must be non-negative. CHECK (imported_key_count >= 0), - -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure - -- that the key scope cannot be deleted if accounts still exist. - FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + -- Composite foreign key to key scopes. This ensures scope_id belongs to + -- the same wallet_id as the account row. Wallet ownership is transitively + -- enforced through key_scopes, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/scope cannot be deleted if + -- accounts still exist. + FOREIGN KEY (wallet_id, scope_id) + REFERENCES key_scopes (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to -- ensure that the origin cannot be deleted if accounts still exist. @@ -95,6 +102,9 @@ CREATE TABLE accounts ( -- Index on foreign scope_id for faster lookups and joins. CREATE INDEX idx_accounts_scope ON accounts (scope_id); +-- Unique index to support composite foreign keys from addresses. +CREATE UNIQUE INDEX uidx_accounts_wallet_id_id ON accounts (wallet_id, id); + -- Unique partial index to prevent duplicate account numbers within the same -- key scope. Only enforced for non-NULL account numbers (derived accounts). -- Imported accounts have NULL account_number and are excluded from this diff --git a/wallet/internal/sql/pg/queries/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql index 0a92f1f307..79c7c542a0 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -31,6 +31,7 @@ SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)); -- to acquire the advisory lock and prevent race conditions. -- See LockAccountScope comments for why this is a separate statement. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -39,15 +40,21 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - $1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 - FROM accounts - WHERE scope_id = $1 - ), - $2, $3, $4, $5, $6 -) + SELECT coalesce(max(a.account_number), -1) + 1 + FROM accounts AS a + WHERE a.scope_id = sqlc.arg('scope_id') + ) AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one @@ -55,6 +62,7 @@ RETURNING id, account_number, created_at; -- number. Imported accounts don't follow BIP44 derivation, so they don't need -- a sequential account number. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -63,7 +71,17 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ($1, NULL, $2, $3, $4, $5, $6) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, created_at; -- name: CreateAccountSecret :exec @@ -265,12 +283,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_number = sqlc.arg(account_number); @@ -280,12 +298,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_name = sqlc.arg(old_name); @@ -293,13 +311,22 @@ WHERE -- Test-only: Creates a derived account with a specific account number. -- Used for testing account number overflow without creating billions of accounts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES ($1, $2, $3, $4, $5) +SELECT + ks.wallet_id, + ks.id AS scope_id, + sqlc.arg('account_number') AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: GetAndIncrementNextExternalIndex :one diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index bfd09a6879..c5d4cb0e1b 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -33,6 +33,7 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr const CreateDerivedAccount = `-- name: CreateDerivedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -41,15 +42,21 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - $1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 - FROM accounts - WHERE scope_id = $1 - ), - $2, $3, $4, $5, $6 -) + SELECT coalesce(max(a.account_number), -1) + 1 + FROM accounts AS a + WHERE a.scope_id = $1 + ) AS account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $1 RETURNING id, account_number, created_at ` @@ -88,22 +95,31 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES ($1, $2, $3, $4, $5) +SELECT + ks.wallet_id, + ks.id AS scope_id, + $1 AS account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $5 RETURNING id, account_number, created_at ` type CreateDerivedAccountWithNumberParams struct { - ScopeID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 IsWatchOnly bool + ScopeID int64 } type CreateDerivedAccountWithNumberRow struct { @@ -116,11 +132,11 @@ type CreateDerivedAccountWithNumberRow struct { // Used for testing account number overflow without creating billions of accounts. func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, - arg.ScopeID, arg.AccountNumber, arg.AccountName, arg.OriginID, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateDerivedAccountWithNumberRow err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) @@ -129,6 +145,7 @@ func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg Create const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -137,17 +154,27 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ($1, NULL, $2, $3, $4, $5, $6) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + $1 AS account_name, + $2 AS origin_id, + $3 AS encrypted_public_key, + $4 AS master_fingerprint, + $5 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $6 RETURNING id, created_at ` type CreateImportedAccountParams struct { - ScopeID int64 AccountName string OriginID int16 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool + ScopeID int64 } type CreateImportedAccountRow struct { @@ -160,12 +187,12 @@ type CreateImportedAccountRow struct { // a sequential account number. func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, - arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, arg.MasterFingerprint, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateImportedAccountRow err := row.Scan(&i.ID, &i.CreatedAt) @@ -846,12 +873,12 @@ UPDATE accounts SET account_name = $1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = $2 - AND purpose = $3 - AND coin_type = $4 + key_scopes.wallet_id = $2 + AND key_scopes.purpose = $3 + AND key_scopes.coin_type = $4 ) AND account_name = $5 ` @@ -884,12 +911,12 @@ UPDATE accounts SET account_name = $1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = $2 - AND purpose = $3 - AND coin_type = $4 + key_scopes.wallet_id = $2 + AND key_scopes.purpose = $3 + AND key_scopes.coin_type = $4 ) AND account_number = $5 ` diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 9173419113..765db782f8 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -11,6 +11,7 @@ import ( type Account struct { ID int64 + WalletID int64 ScopeID int64 AccountNumber sql.NullInt64 AccountName string diff --git a/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql index e99fc90910..2baed4a234 100644 --- a/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql @@ -38,6 +38,9 @@ CREATE TABLE accounts ( -- DB ID of the account, primary key. id INTEGER PRIMARY KEY, + -- Reference to the wallet this account belongs to. + wallet_id INTEGER NOT NULL, + -- Reference to the key scope this account belongs to. scope_id INTEGER NOT NULL, @@ -82,9 +85,13 @@ CREATE TABLE accounts ( -- Imported address counter must be non-negative. CHECK (imported_key_count >= 0), - -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure - -- that the key scope cannot be deleted if accounts still exist. - FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + -- Composite foreign key to key scopes. This ensures scope_id belongs to + -- the same wallet_id as the account row. Wallet ownership is transitively + -- enforced through key_scopes, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/scope cannot be deleted if + -- accounts still exist. + FOREIGN KEY (wallet_id, scope_id) + REFERENCES key_scopes (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to -- ensure that the origin cannot be deleted if accounts still exist. @@ -94,6 +101,9 @@ CREATE TABLE accounts ( -- Index on foreign scope_id for faster lookups and joins. CREATE INDEX idx_accounts_scope ON accounts (scope_id); +-- Unique index to support composite foreign keys from addresses. +CREATE UNIQUE INDEX uidx_accounts_wallet_id_id ON accounts (wallet_id, id); + -- Unique partial index to prevent duplicate account numbers within the same -- key scope. Only enforced for non-NULL account numbers (derived accounts). -- Imported accounts have NULL account_number and are excluded from this diff --git a/wallet/internal/sql/sqlite/queries/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql index ed50418fd2..4aff52c2ed 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -3,6 +3,7 @@ -- account number from existing accounts. SQLite's _txlock=immediate ensures -- only one writer at a time, preventing concurrent allocation conflicts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -11,14 +12,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - ?1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 FROM accounts - WHERE scope_id = ?1 - ), - ?2, ?3, ?4, ?5, ?6 -) + SELECT coalesce(max(a.account_number), -1) + 1 FROM accounts AS a + WHERE a.scope_id = sqlc.arg('scope_id') + ) AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one @@ -26,6 +33,7 @@ RETURNING id, account_number, created_at; -- number. Imported accounts don't follow BIP44 derivation, so they don't need -- a sequential account number. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -34,7 +42,17 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES (?, NULL, ?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, created_at; -- name: CreateAccountSecret :exec @@ -236,12 +254,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_number = sqlc.arg(account_number); @@ -251,12 +269,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_name = sqlc.arg(old_name); @@ -264,13 +282,22 @@ WHERE -- Test-only: Creates a derived account with a specific account number. -- Used for testing account number overflow without creating billions of accounts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES (?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + sqlc.arg('account_number') AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: GetAndIncrementNextExternalIndex :one diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index 481ede7af2..a4789681cd 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -33,6 +33,7 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr const CreateDerivedAccount = `-- name: CreateDerivedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -41,14 +42,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - ?1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 FROM accounts - WHERE scope_id = ?1 - ), - ?2, ?3, ?4, ?5, ?6 -) + SELECT coalesce(max(a.account_number), -1) + 1 FROM accounts AS a + WHERE a.scope_id = ?1 + ) AS account_number, + ?2 AS account_name, + ?3 AS origin_id, + ?4 AS encrypted_public_key, + ?5 AS master_fingerprint, + ?6 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?1 RETURNING id, account_number, created_at ` @@ -86,22 +93,31 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES (?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + ?1 AS account_number, + ?2 AS account_name, + ?3 AS origin_id, + ?4 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?5 RETURNING id, account_number, created_at ` type CreateDerivedAccountWithNumberParams struct { - ScopeID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 IsWatchOnly bool + ScopeID int64 } type CreateDerivedAccountWithNumberRow struct { @@ -114,11 +130,11 @@ type CreateDerivedAccountWithNumberRow struct { // Used for testing account number overflow without creating billions of accounts. func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, - arg.ScopeID, arg.AccountNumber, arg.AccountName, arg.OriginID, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateDerivedAccountWithNumberRow err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) @@ -127,6 +143,7 @@ func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg Create const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -135,17 +152,27 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES (?, NULL, ?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + ?1 AS account_name, + ?2 AS origin_id, + ?3 AS encrypted_public_key, + ?4 AS master_fingerprint, + ?5 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?6 RETURNING id, created_at ` type CreateImportedAccountParams struct { - ScopeID int64 AccountName string OriginID int64 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool + ScopeID int64 } type CreateImportedAccountRow struct { @@ -158,12 +185,12 @@ type CreateImportedAccountRow struct { // a sequential account number. func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, - arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, arg.MasterFingerprint, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateImportedAccountRow err := row.Scan(&i.ID, &i.CreatedAt) @@ -812,12 +839,12 @@ UPDATE accounts SET account_name = ?1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = ?2 - AND purpose = ?3 - AND coin_type = ?4 + key_scopes.wallet_id = ?2 + AND key_scopes.purpose = ?3 + AND key_scopes.coin_type = ?4 ) AND account_name = ?5 ` @@ -850,12 +877,12 @@ UPDATE accounts SET account_name = ?1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = ?2 - AND purpose = ?3 - AND coin_type = ?4 + key_scopes.wallet_id = ?2 + AND key_scopes.purpose = ?3 + AND key_scopes.coin_type = ?4 ) AND account_number = ?5 ` diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index 053824aa10..c9ea1c0e84 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -11,6 +11,7 @@ import ( type Account struct { ID int64 + WalletID int64 ScopeID int64 AccountNumber sql.NullInt64 AccountName string From 017bbc22fac6d18f1bff67124f4e042146b856ae Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:25 -0300 Subject: [PATCH 249/695] wallet: scope addresses to wallet ownership --- wallet/internal/db/addresses_common.go | 20 +++--- .../internal/db/itest/address_store_test.go | 62 ++++++++++++++++++- wallet/internal/db/pg/addresses.go | 10 +-- wallet/internal/db/sqlite/addresses.go | 10 +-- .../sql/pg/migrations/000006_addresses.up.sql | 13 +++- wallet/internal/sql/pg/queries/addresses.sql | 9 +-- wallet/internal/sql/pg/sqlc/addresses.sql.go | 13 ++-- wallet/internal/sql/pg/sqlc/models.go | 1 + .../sqlite/migrations/000006_addresses.up.sql | 13 +++- .../internal/sql/sqlite/queries/addresses.sql | 9 +-- .../internal/sql/sqlite/sqlc/addresses.sql.go | 13 ++-- wallet/internal/sql/sqlite/sqlc/models.go | 1 + 12 files changed, 133 insertions(+), 41 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index cec7c61ec3..e70ac20043 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -390,8 +390,8 @@ type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, GetIntIndex func(QTX) func(context.Context, int64) (int64, error) // CreateAddr returns a function to create an address row. - CreateAddr func(QTX) func(context.Context, int64, AddressType, uint32, - uint32, []byte) (AddrRow, error) + CreateAddr func(QTX) func(context.Context, int64, int64, AddressType, + uint32, uint32, []byte) (AddrRow, error) // RowID extracts the ID from an address row. RowID func(AddrRow) int64 @@ -418,9 +418,9 @@ type ImportedAddressAdapters[QTX any, AccountRow any, // CreateAddr returns a function to create an address row. CreateAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) - // CreateParams converts accountID and params to address creation + // CreateParams converts wallet/account IDs and params to address creation // parameters. - CreateParams func(int64, NewImportedAddressParams) CreateArgs + CreateParams func(int64, int64, NewImportedAddressParams) CreateArgs // InsertSecret returns a function to insert address secret. InsertSecret func(QTX) func(context.Context, SecretParams) error @@ -453,10 +453,10 @@ func GetAddressByQuery(ctx context.Context, query GetAddressQuery, // derived address creation logic. It calls derivedAddressInput to prepare // inputs and then createFn to create the address. func createDerivedAddress[T any](ctx context.Context, - params NewDerivedAddressParams, accountID int64, + params NewDerivedAddressParams, walletID int64, accountID int64, getExtIndex func(context.Context, int64) (int64, error), getIntIndex func(context.Context, int64) (int64, error), - createFn func(context.Context, int64, AddressType, uint32, uint32, + createFn func(context.Context, int64, int64, AddressType, uint32, uint32, []byte) (T, error), rowID func(T) int64, rowCreatedAt func(T) time.Time, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -469,7 +469,9 @@ func createDerivedAddress[T any](ctx context.Context, return nil, err } - row, err := createFn(ctx, accountID, addrType, branch, index, scriptPubKey) + row, err := createFn( + ctx, walletID, accountID, addrType, branch, index, scriptPubKey, + ) if err != nil { return nil, fmt.Errorf("create address: %w", err) } @@ -570,7 +572,7 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { info, errAddr := createDerivedAddress( - ctx, params, adapters.GetAccountID(row), + ctx, params, int64(params.WalletID), adapters.GetAccountID(row), adapters.GetExtIndex(qtx), adapters.GetIntIndex(qtx), adapters.CreateAddr(qtx), @@ -625,7 +627,7 @@ func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, info, errAddr := newImportedAddressTx( ctx, adapters.CreateAddr(qtx), - adapters.CreateParams(acctID, params), + adapters.CreateParams(int64(params.WalletID), acctID, params), adapters.InsertSecret, qtx, adapters.SecretParams, params, acctID, adapters.RowID, diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index e42dbfa842..8fd8a910d1 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -935,6 +935,34 @@ func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { require.ErrorIs(t, err, db.ErrAccountNotFound) } +// TestNewImportedAddressWalletAccountMismatch verifies that imported address +// creation rejects a wallet/scope lookup that only exists in another wallet. +func TestNewImportedAddressWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-import-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-import-mismatch-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0044, "imported", + ) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + }, + ) + require.ErrorIs(t, err, db.ErrAccountNotFound) +} + // TestGetAddressSecret_DerivedAddress verifies that calling GetAddressSecret // on a derived address returns db.ErrSecretNotFound (not ErrAddressNotFound). // This validates the LEFT JOIN: derived addresses exist in the addresses @@ -1514,6 +1542,36 @@ func TestNewDerivedAddressErrors(t *testing.T) { } } +// TestNewDerivedAddress_WalletAccountMismatch verifies that derived address +// creation rejects a wallet/scope/account lookup that resolves in another +// wallet but not in the caller's wallet. +func TestNewDerivedAddress_WalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-derived-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-derived-mismatch-b") + accountName := "shared-name" + + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, accountName, + ) + createDerivedAccount( + t, store, secondWalletID, db.KeyScopeBIP0044, accountName, + ) + + info, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + }, mockDeriveFunc(), + ) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) +} + // TestNewDerivedAddressConcurrent verifies that concurrent address // creation produces unique sequential indexes without conflicts. func TestNewDerivedAddressConcurrent(t *testing.T) { @@ -1706,7 +1764,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { accountID := GetAccountID(t, queries, scopeID, "max-acct") // Insert address at MaxUint32 - 1 - CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) + CreateAddressWithIndex(t, queries, walletID, accountID, 0, math.MaxUint32-1) // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 UpdateAccountNextExternalIndex(t, dbConn, accountID, math.MaxUint32) @@ -1746,7 +1804,7 @@ func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { accountID := GetAccountID(t, queries, scopeID, "max-acct") // Insert address at MaxUint32 - 1 in the internal branch. - CreateAddressWithIndex(t, queries, accountID, 1, math.MaxUint32-1) + CreateAddressWithIndex(t, queries, walletID, accountID, 1, math.MaxUint32-1) // Set the internal counter to MaxUint32 so the next allocation gives us // MaxUint32. diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index d859b095dd..a2ff2f14f6 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -168,11 +168,11 @@ func derivedAddressGetIntIndex(qtx *sqlc.Queries) func(context.Context, // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr(qtx *sqlc.Queries) func( - context.Context, int64, db.AddressType, uint32, uint32, []byte, + context.Context, int64, int64, db.AddressType, uint32, uint32, []byte, ) (sqlc.CreateDerivedAddressRow, error) { - return func(ctx context.Context, accountID int64, addrType db.AddressType, - branch uint32, index uint32, + return func(ctx context.Context, walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { branchNum, err := db.Uint32ToInt16(branch) @@ -184,6 +184,7 @@ func derivedAddressCreateAddr(qtx *sqlc.Queries) func( return qtx.CreateDerivedAddress( ctx, sqlc.CreateDerivedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), @@ -236,10 +237,11 @@ func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, } // createImportedAddressParams maps imported params to sqlc params. -func createImportedAddressParams(accountID int64, +func createImportedAddressParams(walletID int64, accountID int64, params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { return sqlc.CreateImportedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int16(params.AddressType), diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 33cfb386ae..fcba1e2708 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -170,16 +170,17 @@ func derivedAddressGetIntIndex( // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr( qtx *sqlc.Queries, -) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( +) func(context.Context, int64, int64, db.AddressType, uint32, uint32, []byte) ( sqlc.CreateDerivedAddressRow, error, ) { - return func(ctx context.Context, accountID int64, addrType db.AddressType, - branch uint32, index uint32, + return func(ctx context.Context, walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { return qtx.CreateDerivedAddress( ctx, sqlc.CreateDerivedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int64(addrType), @@ -234,10 +235,11 @@ func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, } // createImportedAddressParams maps imported params to sqlc params. -func createImportedAddressParams(accountID int64, +func createImportedAddressParams(walletID int64, accountID int64, params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { return sqlc.CreateImportedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int64(params.AddressType), diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql index 2e9c79ddcb..97799d766e 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql @@ -12,6 +12,9 @@ CREATE TABLE addresses ( -- DB ID of the address, primary key. id BIGSERIAL PRIMARY KEY, + -- Reference to the wallet this address belongs to. + wallet_id BIGINT NOT NULL, + -- Reference to the account this address belongs to. account_id BIGINT NOT NULL, @@ -51,9 +54,13 @@ CREATE TABLE addresses ( -- Address index must be non-negative when set. CHECK (address_index IS NULL OR address_index >= 0), - -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure - -- that the account cannot be deleted if addresses still exist. - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + -- Composite foreign key to accounts. This ensures account_id belongs to + -- the same wallet_id as the address row. Wallet ownership is transitively + -- enforced through accounts, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/account cannot be deleted if + -- addresses still exist. + FOREIGN KEY (wallet_id, account_id) + REFERENCES accounts (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to address types. Using ON DELETE RESTRICT to -- ensure that the address type cannot be deleted if addresses still exist. diff --git a/wallet/internal/sql/pg/queries/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql index a76546339a..1189d8f452 100644 --- a/wallet/internal/sql/pg/queries/addresses.sql +++ b/wallet/internal/sql/pg/queries/addresses.sql @@ -25,9 +25,8 @@ SELECT (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = $1 AND ks.wallet_id = $2; +WHERE a.script_pub_key = $1 AND a.wallet_id = $2; -- name: GetAddressSecret :one -- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -47,18 +46,20 @@ WHERE a.id = $1; -- The index is allocated separately via GetAndIncrementNextExternalIndex -- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES ($1, $2, $3, $4, $5, $6) +) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at; -- name: CreateImportedAddress :one -- Creates an imported address (no derivation path, has script/pubkey). INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -66,7 +67,7 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - $1, $2, $3, NULL, NULL, $4 + $1, $2, $3, $4, NULL, NULL, $5 ) RETURNING id, created_at; diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index d6ce305551..2bd233363a 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -13,17 +13,19 @@ import ( const CreateDerivedAddress = `-- name: CreateDerivedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES ($1, $2, $3, $4, $5, $6) +) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at ` type CreateDerivedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 @@ -42,6 +44,7 @@ type CreateDerivedAddressRow struct { // or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -56,6 +59,7 @@ func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAdd const CreateImportedAddress = `-- name: CreateImportedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -63,12 +67,13 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - $1, $2, $3, NULL, NULL, $4 + $1, $2, $3, $4, NULL, NULL, $5 ) RETURNING id, created_at ` type CreateImportedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 @@ -83,6 +88,7 @@ type CreateImportedAddressRow struct { // Creates an imported address (no derivation path, has script/pubkey). func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -108,9 +114,8 @@ SELECT (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = $1 AND ks.wallet_id = $2 +WHERE a.script_pub_key = $1 AND a.wallet_id = $2 ` type GetAddressByScriptPubKeyParams struct { diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 765db782f8..147641ec2b 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -37,6 +37,7 @@ type AccountSecret struct { type Address struct { ID int64 + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 diff --git a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql index 72a58dc139..b4f5a1541f 100644 --- a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql @@ -12,6 +12,9 @@ CREATE TABLE addresses ( -- DB ID of the address, primary key. id INTEGER PRIMARY KEY, + -- Reference to the wallet this address belongs to. + wallet_id INTEGER NOT NULL, + -- Reference to the account this address belongs to. account_id INTEGER NOT NULL, @@ -48,9 +51,13 @@ CREATE TABLE addresses ( -- Address index must be non-negative when set. CHECK (address_index IS NULL OR address_index >= 0), - -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure - -- that the account cannot be deleted if addresses still exist. - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + -- Composite foreign key to accounts. This ensures account_id belongs to + -- the same wallet_id as the address row. Wallet ownership is transitively + -- enforced through accounts, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/account cannot be deleted if + -- addresses still exist. + FOREIGN KEY (wallet_id, account_id) + REFERENCES accounts (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to address types. Using ON DELETE RESTRICT to -- ensure that the address type cannot be deleted if addresses still exist. diff --git a/wallet/internal/sql/sqlite/queries/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql index dc9db4a85d..9b08739823 100644 --- a/wallet/internal/sql/sqlite/queries/addresses.sql +++ b/wallet/internal/sql/sqlite/queries/addresses.sql @@ -25,9 +25,8 @@ SELECT s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = ? AND ks.wallet_id = ?; +WHERE a.script_pub_key = ? AND a.wallet_id = ?; -- name: GetAddressSecret :one -- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -47,18 +46,20 @@ WHERE a.id = ?; -- The index is allocated separately via GetAndIncrementNextExternalIndex -- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id, created_at; -- name: CreateImportedAddress :one -- Creates an imported address (no derivation path, has script/pubkey). INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -66,7 +67,7 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - ?1, ?2, ?3, NULL, NULL, ?4 + ?1, ?2, ?3, ?4, NULL, NULL, ?5 ) RETURNING id, created_at; diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index cd60257e8e..aed450d5a6 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -13,17 +13,19 @@ import ( const CreateDerivedAddress = `-- name: CreateDerivedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id, created_at ` type CreateDerivedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 @@ -42,6 +44,7 @@ type CreateDerivedAddressRow struct { // or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -56,6 +59,7 @@ func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAdd const CreateImportedAddress = `-- name: CreateImportedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -63,12 +67,13 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - ?1, ?2, ?3, NULL, NULL, ?4 + ?1, ?2, ?3, ?4, NULL, NULL, ?5 ) RETURNING id, created_at ` type CreateImportedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 @@ -83,6 +88,7 @@ type CreateImportedAddressRow struct { // Creates an imported address (no derivation path, has script/pubkey). func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -108,9 +114,8 @@ SELECT s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = ? AND ks.wallet_id = ? +WHERE a.script_pub_key = ? AND a.wallet_id = ? ` type GetAddressByScriptPubKeyParams struct { diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index c9ea1c0e84..496c2b06b3 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -37,6 +37,7 @@ type AccountSecret struct { type Address struct { ID int64 + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 From 4bf14f736aebc1b8ddedc5237daa282b5bdbc5e7 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:31 -0300 Subject: [PATCH 250/695] wallet: make address scripts unique per wallet --- .../internal/db/itest/address_store_test.go | 139 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 49 ++++++ .../internal/db/itest/fixtures_sqlite_test.go | 44 ++++++ .../sql/pg/migrations/000006_addresses.up.sql | 10 +- .../sqlite/migrations/000006_addresses.up.sql | 10 +- 5 files changed, 238 insertions(+), 14 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 8fd8a910d1..a73e00604b 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -541,6 +541,145 @@ func TestNewImportedAddressDuplicate(t *testing.T) { require.ErrorContains(t, err, "script_pub_key") } +// TestNewImportedAddressDuplicateAcrossScopes verifies that imported-address +// creation rejects the same script pubkey across different imported scopes in +// one wallet. +func TestNewImportedAddressDuplicateAcrossScopes(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-import-cross-scope") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + scriptPubKey := RandomBytes(32) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + + _, err = store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") + require.ErrorContains(t, err, "script_pub_key") +} + +// TestNewImportedAddressDuplicateAcrossWallets verifies that wallet-scoped +// uniqueness allows the same script pubkey to be imported into different +// wallets. +func TestNewImportedAddressDuplicateAcrossWallets(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-duplicate-import-a") + secondWalletID := newWallet(t, store, "wallet-duplicate-import-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, "imported", + ) + + scriptPubKey := RandomBytes(32) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: firstWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + + info, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) +} + +// TestCreateImportedAddressRejectsWalletAccountMismatch verifies that the +// composite wallet/account invariant is enforced by the database on direct +// imported-address inserts. +func TestCreateImportedAddressRejectsWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-import-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-import-mismatch-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, "imported", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + firstAccountID := GetAccountID(t, queries, firstScopeID, "imported") + + err := createImportedAddressRaw( + t.Context(), queries, secondWalletID, firstAccountID, RandomBytes(32), + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateDerivedAddressRejectsWalletAccountMismatch verifies that the +// composite wallet/account invariant is enforced by the database on direct +// derived-address inserts. +func TestCreateDerivedAddressRejectsWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-derived-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-derived-mismatch-b") + accountName := "raw-derived" + + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, accountName, + ) + createDerivedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, accountName, + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + firstAccountID := GetAccountID(t, queries, firstScopeID, accountName) + + err := createDerivedAddressRaw( + t, queries, secondWalletID, firstAccountID, 0, 0, RandomBytes(20), + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + // TestGetAddressSecret verifies that GetAddressSecret correctly retrieves // address secrets for watch-only imported addresses and returns an error for // spendable addresses or non-existent address IDs. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 8633a4f827..d276076cce 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -250,3 +250,52 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") } + +// createImportedAddressRaw inserts an imported address directly through the +// database so tests can validate wallet/account ownership invariants. +func createImportedAddressRaw(ctx context.Context, queries *sqlc.Queries, + walletID uint32, accountID int64, scriptPubKey []byte) error { + + _, err := queries.CreateImportedAddress( + ctx, sqlc.CreateImportedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(db.WitnessPubKey), + PubKey: RandomBytes(33), + }, + ) + + return err +} + +// createDerivedAddressRaw inserts a derived address directly through +// PostgreSQL sqlc queries for testing database-level invariants. +func createDerivedAddressRaw(t *testing.T, queries *sqlc.Queries, + walletID uint32, accountID int64, branch uint32, index uint32, + scriptPubKey []byte) error { + t.Helper() + + branchNum, err := db.Uint32ToInt16(branch) + require.NoError(t, err) + + _, err = queries.CreateDerivedAddress( + t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(db.WitnessPubKey), + AddressBranch: sql.NullInt16{ + Int16: branchNum, + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + + return err +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 9ba8b38302..c7e2b995e7 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -250,3 +250,47 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") } + +// createImportedAddressRaw inserts an imported address directly through the +// database so tests can validate wallet/account ownership invariants. +func createImportedAddressRaw(ctx context.Context, queries *sqlc.Queries, + walletID uint32, accountID int64, scriptPubKey []byte) error { + + _, err := queries.CreateImportedAddress( + ctx, sqlc.CreateImportedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(db.WitnessPubKey), + PubKey: RandomBytes(33), + }, + ) + + return err +} + +// createDerivedAddressRaw inserts a derived address directly through the +// database so tests can validate wallet/account ownership invariants. +func createDerivedAddressRaw(t *testing.T, queries *sqlc.Queries, + walletID uint32, accountID int64, branch uint32, index uint32, + scriptPubKey []byte) error { + _, err := queries.CreateDerivedAddress( + t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(db.WitnessPubKey), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + + return err +} diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql index 97799d766e..31125583f0 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql @@ -75,13 +75,9 @@ ON addresses (account_id, address_branch, address_index) WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique index to prevent duplicate script_pub_key within the same account. -CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key); - --- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey. -CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Unique index to prevent duplicate script_pub_key within the same wallet. +CREATE UNIQUE INDEX uidx_addresses_wallet_script_pub_key +ON addresses (wallet_id, script_pub_key); -- Index on (account_id, id) for efficient pagination of addresses by account. -- Used by ListAddressesByAccount for cursor-based pagination. diff --git a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql index b4f5a1541f..2fe9009af1 100644 --- a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql @@ -73,13 +73,9 @@ WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique index to prevent duplicate script_pub_key within the same account. -CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key); - --- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey. -CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Unique index to prevent duplicate script_pub_key within the same wallet. +CREATE UNIQUE INDEX uidx_addresses_wallet_script_pub_key +ON addresses (wallet_id, script_pub_key); -- Index on (account_id, id) for efficient pagination of addresses by account. -- Used by ListAddressesByAccount for cursor-based pagination. From 5a1ae24e208c7a0e2817f6a4f48e911654eba33f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 20 Apr 2026 23:18:44 -0300 Subject: [PATCH 251/695] wallet: scope GetAddressSecret to wallet_id --- wallet/internal/db/addresses_common.go | 9 +- wallet/internal/db/data_types.go | 14 +++ wallet/internal/db/interface.go | 4 +- .../internal/db/itest/address_store_test.go | 89 ++++++++++++++++--- wallet/internal/db/itest/fixtures_pg_test.go | 7 -- .../internal/db/itest/fixtures_sqlite_test.go | 7 -- wallet/internal/db/pg/addresses.go | 15 +++- wallet/internal/db/sqlite/addresses.go | 16 +++- wallet/internal/sql/pg/queries/addresses.sql | 2 +- wallet/internal/sql/pg/sqlc/addresses.sql.go | 11 ++- wallet/internal/sql/pg/sqlc/querier.go | 2 +- .../internal/sql/sqlite/queries/addresses.sql | 2 +- .../internal/sql/sqlite/sqlc/addresses.sql.go | 11 ++- wallet/internal/sql/sqlite/sqlc/querier.go | 2 +- 14 files changed, 145 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e70ac20043..e5ba932de1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -62,17 +62,18 @@ func AccountKeyFromImportedParams( // information using the provided getter function and converts it to an // AddressSecret with error handling. func GetAddressSecret[Row any](ctx context.Context, - getter func(context.Context, int64) (Row, error), addressID uint32, + getter func(context.Context, int64, int64) (Row, error), + query GetAddressSecretQuery, toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { - row, err := getter(ctx, int64(addressID)) + row, err := getter(ctx, int64(query.WalletID), int64(query.AddressID)) if err == nil { return toSecret(row) } if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("address secret for address %d: %w", - addressID, ErrAddressNotFound) + return nil, fmt.Errorf("address secret for wallet %d address %d: %w", + query.WalletID, query.AddressID, ErrAddressNotFound) } return nil, fmt.Errorf("get address secret: %w", err) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 13f1cce018..ff0bf458da 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -705,6 +705,20 @@ type GetAddressQuery struct { ScriptPubKey []byte } +// GetAddressSecretQuery contains the parameters for querying an address +// secret. The query is wallet-scoped: it retrieves the encrypted secret +// material for a specific address within a wallet. +type GetAddressSecretQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AddressID is the ID of the address whose secret should be fetched. + AddressID uint32 +} + // ListAddressesQuery contains the parameters for listing addresses. type ListAddressesQuery struct { // WalletID is the ID of the wallet to query. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 49f531beec..35e5a39e25 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -260,8 +260,8 @@ type AddressStore interface { // GetAddressSecret retrieves the encrypted secret material for a given // address. Returns the AddressSecret containing encrypted private key // and scripts, or an error if the secret does not exist. - GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, - error) + GetAddressSecret(ctx context.Context, + query GetAddressSecretQuery) (*AddressSecret, error) // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index a73e00604b..7678301efa 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -251,16 +251,21 @@ func TestNewImportedAddress(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := GetAddressSecret(t, queries, addressID) - require.NoError(t, err) + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: uint32(addressID), + }, + ) + if tc.providePrivateKey { + require.NoError(t, err) require.Equal( t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, ) require.Empty(t, secret.EncryptedScript) } else { - require.Empty(t, secret.EncryptedPrivKey) - require.Empty(t, secret.EncryptedScript) + require.ErrorIs(t, err, db.ErrSecretNotFound) } }) } @@ -364,16 +369,31 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := GetAddressSecret(t, queries, addressID) - require.NoError(t, err) - + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: uint32(addressID), + }, + ) require.Equal(t, tc.encryptedScript, secret.EncryptedScript) if tc.hasPrivateKey { + require.NoError(t, err) require.Equal( t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, ) + + return + } + + if tc.hasScript { + require.NoError(t, err) + require.Equal(t, params.EncryptedScript, secret.EncryptedScript) + + return } + + require.ErrorAs(t, err, &db.ErrSecretNotFound) }) } } @@ -725,7 +745,12 @@ func TestGetAddressSecret(t *testing.T) { require.NoError(t, errNewAddr) if tc.shouldHaveSecret { - secret, err := store.GetAddressSecret(t.Context(), info.ID) + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: info.ID, + }, + ) require.NoError(t, err) require.NotNil(t, secret) require.Equal(t, info.ID, secret.AddressID) @@ -734,15 +759,52 @@ func TestGetAddressSecret(t *testing.T) { ) require.Empty(t, secret.EncryptedScript) } else { - _, err := store.GetAddressSecret(t.Context(), info.ID) + _, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: info.ID, + }, + ) require.ErrorIs(t, err, db.ErrSecretNotFound) } }) } + t.Run("cross-wallet address denied", func(t *testing.T) { + otherWalletID := newWallet(t, store, "wallet-secrets-other") + CreateImportedAccount( + t, store, otherWalletID, db.KeyScopeBIP0044, "imported", + ) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + EncryptedPrivateKey: RandomBytes(32), + } + + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + _, err = store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: otherWalletID, + AddressID: info.ID, + }, + ) + require.ErrorIs(t, err, db.ErrAddressNotFound) + }) + // Test non-existent address ID. t.Run("non-existent address", func(t *testing.T) { - _, err := store.GetAddressSecret(t.Context(), 999999) + _, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: 999999, + }, + ) require.ErrorIs(t, err, db.ErrAddressNotFound) }) } @@ -1125,7 +1187,12 @@ func TestGetAddressSecret_DerivedAddress(t *testing.T) { // Attempt to get secret for derived address. // Derived addresses have no row in address_secrets table. - _, err = store.GetAddressSecret(t.Context(), addrInfo.ID) + _, err = store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: addrInfo.ID, + }, + ) // Expect ErrSecretNotFound (not ErrAddressNotFound) because the // LEFT JOIN returns a row with NULL encrypted_priv_key. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index d276076cce..9f0a79ce21 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -199,13 +199,6 @@ func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlc.Queries, - addressID int64) (sqlc.GetAddressSecretRow, error) { - t.Helper() - - return queries.GetAddressSecret(t.Context(), addressID) -} - // MustDeleteAddress deletes an address by ID for test scenarios. func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index c7e2b995e7..6901e74208 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -199,13 +199,6 @@ func getAddressID(t *testing.T, queries *sqlc.Queries, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlc.Queries, - addressID int64) (sqlc.GetAddressSecretRow, error) { - t.Helper() - - return queries.GetAddressSecret(t.Context(), addressID) -} - // MustDeleteAddress deletes an address by ID for test scenarios. func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { t.Helper() diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index a2ff2f14f6..74bc32b469 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -68,10 +68,21 @@ func (s *Store) IterAddresses(ctx context.Context, // GetAddressSecret retrieves the encrypted secret information for an address. func (s *Store) GetAddressSecret(ctx context.Context, - addressID uint32) (*db.AddressSecret, error) { + query db.GetAddressSecretQuery) (*db.AddressSecret, error) { + + getSecret := func(ctx context.Context, walletID int64, + addressID int64) (sqlc.GetAddressSecretRow, error) { + + return s.queries.GetAddressSecret( + ctx, sqlc.GetAddressSecretParams{ + WalletID: walletID, + ID: addressID, + }, + ) + } return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, addressSecretRowToSecret, + ctx, getSecret, query, addressSecretRowToSecret, ) } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index fcba1e2708..32a293088a 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -68,11 +68,21 @@ func (s *Store) IterAddresses(ctx context.Context, // GetAddressSecret retrieves the encrypted secret information for an address. func (s *Store) GetAddressSecret(ctx context.Context, - addressID uint32) (*db.AddressSecret, error) { + query db.GetAddressSecretQuery) (*db.AddressSecret, error) { + + getSecret := func(ctx context.Context, walletID int64, + addressID int64) (sqlc.GetAddressSecretRow, error) { + + return s.queries.GetAddressSecret( + ctx, sqlc.GetAddressSecretParams{ + WalletID: walletID, + ID: addressID, + }, + ) + } return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, - addressSecretRowToSecret, + ctx, getSecret, query, addressSecretRowToSecret, ) } diff --git a/wallet/internal/sql/pg/queries/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql index 1189d8f452..63c6822aa4 100644 --- a/wallet/internal/sql/pg/queries/addresses.sql +++ b/wallet/internal/sql/pg/queries/addresses.sql @@ -39,7 +39,7 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = $1; +WHERE a.wallet_id = $1 AND a.id = $2; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index 2bd233363a..fde4d3d29b 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -164,9 +164,14 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = $1 +WHERE a.wallet_id = $1 AND a.id = $2 ` +type GetAddressSecretParams struct { + WalletID int64 + ID int64 +} + type GetAddressSecretRow struct { AddressID int64 EncryptedPrivKey []byte @@ -177,8 +182,8 @@ type GetAddressSecretRow struct { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) -func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { - row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) +func (q *Queries) GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, arg.WalletID, arg.ID) var i GetAddressSecretRow err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) return i, err diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 963865d03c..98bf79857a 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -156,7 +156,7 @@ type Querier interface { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) - GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) + GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) // Atomically gets the next external address index and increments the counter. diff --git a/wallet/internal/sql/sqlite/queries/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql index 9b08739823..e2e12c4c31 100644 --- a/wallet/internal/sql/sqlite/queries/addresses.sql +++ b/wallet/internal/sql/sqlite/queries/addresses.sql @@ -39,7 +39,7 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = ?; +WHERE a.wallet_id = ? AND a.id = ?; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index aed450d5a6..c31c66c70f 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -164,9 +164,14 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = ? +WHERE a.wallet_id = ? AND a.id = ? ` +type GetAddressSecretParams struct { + WalletID int64 + ID int64 +} + type GetAddressSecretRow struct { AddressID int64 EncryptedPrivKey []byte @@ -177,8 +182,8 @@ type GetAddressSecretRow struct { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) -func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { - row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) +func (q *Queries) GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, arg.WalletID, arg.ID) var i GetAddressSecretRow err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) return i, err diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 8586c40cc1..eef3bf58bd 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -153,7 +153,7 @@ type Querier interface { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) - GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) + GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) // Atomically gets the next external address index and increments the counter. From 1f7ad0e20e538075d3c70489dea5de6024256506 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:44:40 +0800 Subject: [PATCH 252/695] db/err: preserve domain errors Keep non-SQL domain errors unchanged in shared normalization while still preserving existing SQL wrappers and transport classification. --- wallet/internal/db/err/errors.go | 14 ++------------ wallet/internal/db/err/errors_test.go | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/wallet/internal/db/err/errors.go b/wallet/internal/db/err/errors.go index ee6654f342..04e3b47add 100644 --- a/wallet/internal/db/err/errors.go +++ b/wallet/internal/db/err/errors.go @@ -335,9 +335,7 @@ func (e *SQLError) Unwrap() error { // errors into SQLError values. // 5. Generic connection and transport failures are classified as // ReasonUnavailable. -// 6. If the backend is known and no earlier rule matches, err is wrapped as -// ReasonUnknown. -// 7. If the backend is unknown, err is returned unchanged. +// 6. Otherwise err is returned unchanged. // // Normalize is intentionally side-effect free. Callers own follow-up actions // such as stats recording or unhealthy-store transitions. @@ -378,15 +376,7 @@ func Normalize(backend Backend, mapper func(error) *SQLError, err error) error { return transportErr } - // Unknown backends are left untouched so callers do not accidentally invent - // a backend identity the error never had. - if !backend.Valid() { - return err - } - - // Keep backend identity even when the exact backend code is not recognized - // so logs and stats retain useful diagnosis data. - return NewSQLError(backend, ReasonUnknown, "", err) + return err } // extractSQLError extracts the shared SQL error wrapper from err, if present. diff --git a/wallet/internal/db/err/errors_test.go b/wallet/internal/db/err/errors_test.go index 9a4a9b6024..6001809ccd 100644 --- a/wallet/internal/db/err/errors_test.go +++ b/wallet/internal/db/err/errors_test.go @@ -14,8 +14,7 @@ import ( var errConstraint = errors.New("constraint") -// noOpMapper is a test helper that leaves backend-specific classification to -// later Normalize fallbacks. +// noOpMapper is a test helper that disables backend-specific classification. func noOpMapper(error) *SQLError { return nil } @@ -206,9 +205,13 @@ func TestNormalize(t *testing.T) { require.Equal(t, ClassTransient, sqlErr.Class()) require.Equal(t, ReasonUnavailable, sqlErr.Reason) - err = Normalize(BackendSQLite, func(in error) *SQLError { - return NewSQLError(BackendSQLite, ReasonUnknown, "", in) - }, errConstraint) + err = Normalize( + BackendSQLite, + func(in error) *SQLError { + return NewSQLError(BackendSQLite, ReasonUnknown, "", in) + }, + errConstraint, + ) sqlErr = extractSQLError(err) require.NotNil(t, sqlErr) require.Equal(t, ClassPermanent, sqlErr.Class()) @@ -225,7 +228,12 @@ func TestNormalize(t *testing.T) { require.Equal(t, Backend(""), extractSQLError(err).Backend) require.Same(t, errConstraint, - Normalize(Backend(""), noOpMapper, errConstraint)) + Normalize(BackendPostgres, noOpMapper, errConstraint)) + + require.Same(t, errConstraint, + func() error { + return Normalize(Backend(""), noOpMapper, errConstraint) + }()) } // temporaryNetError is a test helper that exposes only the legacy Temporary From 6fb6bfb3cce3fd5d996d2fabb96a3e369161f9a5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:44:59 +0800 Subject: [PATCH 253/695] db: add runtime stats snapshots Add shared runtime stats types and expose them through the main store interface so callers can read retry, health, ambiguous commit, and SQL error counters. Non-SQL stores return an empty runtime snapshot. --- wallet/internal/db/interface.go | 10 +-- wallet/internal/db/kvdb/driver.go | 6 ++ wallet/internal/db/runtime/runtime.go | 99 +++++++++++++++++++++++++++ wallet/mock_test.go | 7 ++ 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 35e5a39e25..fdcc8bf222 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -6,6 +6,7 @@ import ( "iter" "github.com/btcsuite/btcwallet/wallet/internal/db/page" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" ) var ( @@ -127,10 +128,13 @@ var ( // TODO(yy): Break down wallet managers into independent components. // // TODO(yy): Remove the linter ignore once Store grows beyond UTXOStore. -// -//nolint:iface // Transitional alias until Store grows beyond UTXOStore. type Store interface { UTXOStore + + // StatsSnapshot returns the current runtime counters tracked by the + // backend. + // Backends without SQL classification support may return an empty snapshot. + StatsSnapshot() dbruntime.StatsSnapshot } // WalletStore defines the methods for wallet-level operations. @@ -363,8 +367,6 @@ type TxStore interface { } // UTXOStore defines the database actions for managing the UTXO set. -// -//nolint:iface // Store is a transitional wrapper over UTXOStore. type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details diff --git a/wallet/internal/db/kvdb/driver.go b/wallet/internal/db/kvdb/driver.go index 0eb5b35eaf..f69fbd70aa 100644 --- a/wallet/internal/db/kvdb/driver.go +++ b/wallet/internal/db/kvdb/driver.go @@ -2,6 +2,7 @@ package kvdb import ( "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -26,3 +27,8 @@ func NewStore(dbConn walletdb.DB, txStore wtxmgr.TxStore) *Store { txStore: txStore, } } + +// StatsSnapshot returns an empty runtime snapshot for the kvdb backend. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return dbruntime.StatsSnapshot{} +} diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index 1c1b83300e..861fb7f777 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "sync/atomic" "time" dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" @@ -161,6 +162,104 @@ type readConfig struct { timer func(time.Duration) *time.Timer } +// StatsSnapshot is a read-only copy of the shared runtime counters. +type StatsSnapshot struct { + // Unhealthy reports whether fatal SQL failures poisoned the store. + Unhealthy bool + + // RetryAttempts counts read retry attempts. + RetryAttempts uint64 + + // RetrySuccesses counts reads that later succeeded after retry. + RetrySuccesses uint64 + + // RetryExhausted counts read retries that exhausted their budget. + RetryExhausted uint64 + + // AmbiguousTxCommits counts commit failures with unknown outcome. + AmbiguousTxCommits uint64 + + // Errors snapshots the classified SQL error counters. + Errors dberr.StatsSnapshot +} + +// Stats stores low-overhead atomic counters for shared runtime behavior. +type Stats struct { + // unhealthy reports whether fatal SQL failures poisoned the store. + unhealthy atomic.Bool + + // errStats aggregates classified SQL error counts. + errStats dberr.Stats + + // retryAttempts counts read retry attempts. + retryAttempts atomic.Uint64 + + // retrySuccesses counts reads that later succeeded after retry. + retrySuccesses atomic.Uint64 + + // retryExhausted counts read retries that exhausted their budget. + retryExhausted atomic.Uint64 + + // ambiguousTxCommits counts commit failures with unknown outcome. + ambiguousTxCommits atomic.Uint64 +} + +// CheckHealthy reports whether fatal SQL failures already poisoned the store. +func (s *Stats) CheckHealthy() error { + if s.unhealthy.Load() { + return ErrStoreUnhealthy + } + + return nil +} + +// RecordError updates the shared SQL error counters and unhealthy state. +func (s *Stats) RecordError(err error) { + s.errStats.Record(err) + + var sqlErr *dberr.SQLError + if !errors.As(err, &sqlErr) { + return + } + + if sqlErr.Class() == dberr.ClassFatal { + s.unhealthy.Store(true) + } +} + +// RecordRetryAttempt records one read retry attempt. +func (s *Stats) RecordRetryAttempt() { + s.retryAttempts.Add(1) +} + +// RecordRetrySuccess records one successful read retry outcome. +func (s *Stats) RecordRetrySuccess() { + s.retrySuccesses.Add(1) +} + +// RecordRetryExhausted records one read retry sequence that exhausted its +// budget. +func (s *Stats) RecordRetryExhausted() { + s.retryExhausted.Add(1) +} + +// RecordAmbiguousTxCommit records one commit failure with unknown outcome. +func (s *Stats) RecordAmbiguousTxCommit() { + s.ambiguousTxCommits.Add(1) +} + +// Snapshot returns a read-only copy of the runtime counters. +func (s *Stats) Snapshot(backend dberr.Backend) StatsSnapshot { + return StatsSnapshot{ + Unhealthy: s.unhealthy.Load(), + RetryAttempts: s.retryAttempts.Load(), + RetrySuccesses: s.retrySuccesses.Load(), + RetryExhausted: s.retryExhausted.Load(), + AmbiguousTxCommits: s.ambiguousTxCommits.Load(), + Errors: s.errStats.Snapshot(backend), + } +} + // Read executes a read-only SQL callback with transient retry handling. // // Read returns the callback result from the first successful attempt. On any diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 8d3f12f01a..14857c5247 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -23,6 +23,7 @@ import ( "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" @@ -43,6 +44,12 @@ type mockStore struct { // interface. var _ db.Store = (*mockStore)(nil) +// StatsSnapshot returns an empty runtime snapshot for tests that use the mock +// kvdb store. +func (m *mockStore) StatsSnapshot() dbruntime.StatsSnapshot { + return dbruntime.StatsSnapshot{} +} + // GetUtxo implements the db.UTXOStore interface. func (m *mockStore) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { From 5d91700ca8181a2d5a08d299acf3967638e83752 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:48:52 +0800 Subject: [PATCH 254/695] db/sqlite: add runtime store helpers Add sqlite-local runtime adapters and store state so later call-site changes can share read retry, SQL error classification, unhealthy-store tracking, and write execution semantics. --- wallet/internal/db/sqlite/runtime.go | 125 ++++++++++++++++++++++ wallet/internal/db/sqlite/runtime_test.go | 118 ++++++++++++++++++++ wallet/internal/db/sqlite/store.go | 9 +- 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/sqlite/runtime.go create mode 100644 wallet/internal/db/sqlite/runtime_test.go diff --git a/wallet/internal/db/sqlite/runtime.go b/wallet/internal/db/sqlite/runtime.go new file mode 100644 index 0000000000..11938e8929 --- /dev/null +++ b/wallet/internal/db/sqlite/runtime.go @@ -0,0 +1,125 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +var ( + // Compile-time interface checks keep Store aligned with the shared runtime + // hook contracts. + _ dbruntime.ReadHooks = (*Store)(nil) + _ dbruntime.WriteHooks = (*Store)(nil) +) + +// Default SQLite read retry settings. +const ( + // defaultReadMaxAttempts keeps retries bounded to one initial attempt plus + // up to two retries for short-lived lock contention. + defaultReadMaxAttempts = 3 + + // defaultReadBaseDelay starts backoff at 10 ms so brief lock contention can + // clear without noticeably delaying callers. + defaultReadBaseDelay = 10 * time.Millisecond + + // defaultReadMaxDelay caps retry backoff at 100 ms so repeated transient + // errors do not stall wallet operations for too long. + defaultReadMaxDelay = 100 * time.Millisecond +) + +// execRead executes one SQLite read operation through the shared runtime +// helper. +func (s *Store) execRead(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Read( + ctx, s, s.queries, defaultReadConfig(), + func(_ context.Context, q *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(q) + }, + ) + + return err +} + +// execWrite executes one SQLite write operation through the shared runtime +// helper. +func (s *Store) execWrite(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Write( + ctx, s, + func(tx *sql.Tx) *sqlc.Queries { + return s.queries.WithTx(tx) + }, + func(qtx *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(qtx) + }, + ) + + return err +} + +// defaultReadConfig returns the SQLite read retry policy. +func defaultReadConfig() dbruntime.ReadConfig { + // TODO(yy): make it configurable. + return dbruntime.ReadConfig{ + MaxAttempts: defaultReadMaxAttempts, + BaseDelay: defaultReadBaseDelay, + MaxDelay: defaultReadMaxDelay, + } +} + +// CheckHealthy reports whether a prior fatal SQL backend error poisoned the +// store. +func (s *Store) CheckHealthy() error { + return s.runtimeStats.CheckHealthy() +} + +// ClassifyError normalizes one SQLite backend error into the shared SQL error +// model while preserving ordinary domain errors unchanged. +func (s *Store) ClassifyError(err error) error { + return dberr.Normalize(dberr.BackendSQLite, mapErr, err) +} + +// RecordError records one classified SQLite backend error and marks the store +// unhealthy after fatal failures. +func (s *Store) RecordError(err error) { + s.runtimeStats.RecordError(err) +} + +// RecordRetryAttempt records one SQLite read retry attempt. +func (s *Store) RecordRetryAttempt() { + s.runtimeStats.RecordRetryAttempt() +} + +// RecordRetrySuccess records one successful SQLite read retry outcome. +func (s *Store) RecordRetrySuccess() { + s.runtimeStats.RecordRetrySuccess() +} + +// RecordRetryExhausted records one exhausted SQLite read retry sequence. +func (s *Store) RecordRetryExhausted() { + s.runtimeStats.RecordRetryExhausted() +} + +// RecordAmbiguousTxCommit records one SQLite commit failure with unknown +// outcome. +func (s *Store) RecordAmbiguousTxCommit() { + s.runtimeStats.RecordAmbiguousTxCommit() +} + +// RawDB returns the SQLite database handle used by shared runtime writes. +func (s *Store) RawDB() *sql.DB { + return s.db +} + +// StatsSnapshot returns the current SQLite runtime counters. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return s.runtimeStats.Snapshot(dberr.BackendSQLite) +} diff --git a/wallet/internal/db/sqlite/runtime_test.go b/wallet/internal/db/sqlite/runtime_test.go new file mode 100644 index 0000000000..021f9cac61 --- /dev/null +++ b/wallet/internal/db/sqlite/runtime_test.go @@ -0,0 +1,118 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "testing" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/stretchr/testify/require" +) + +// TestClassifyErrorReturnsOriginalErrors verifies that SQLite classification +// preserves domain and already-classified errors unchanged. +func TestClassifyErrorReturnsOriginalErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + errDup := dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonConstraint, "19", sql.ErrTxDone, + ) + tests := []struct { + name string + err error + }{ + {name: "wallet not found", err: db.ErrWalletNotFound}, + {name: "tx not found", err: db.ErrTxNotFound}, + {name: "generic error", err: sql.ErrNoRows}, + {name: "existing sql error", err: errDup}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Same(t, test.err, store.ClassifyError(test.err)) + }) + } +} + +// TestClassifyErrorTransportError verifies that SQLite transport failures are +// classified as shared unavailable SQL errors. +func TestClassifyErrorTransportError(t *testing.T) { + t.Parallel() + + store := &Store{} + classifiedErr := store.ClassifyError(sql.ErrConnDone) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) +} + +// TestClassifyErrorBackendConstraint verifies that SQLite constraint failures +// are classified as shared SQL constraint errors. +func TestClassifyErrorBackendConstraint(t *testing.T) { + t.Parallel() + + store := &Store{} + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT UNIQUE)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext( + ctx, `INSERT INTO demo (val) VALUES ('dup')`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext( + ctx, `INSERT INTO demo (val) VALUES ('dup')`, + ) + require.Error(t, err) + + classifiedErr := store.ClassifyError(err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) +} + +// TestClassifyErrorUnknownBackendError verifies that unmapped SQLite-native +// errors still remain wrapped as shared SQL errors with ReasonUnknown. +func TestClassifyErrorUnknownBackendError(t *testing.T) { + t.Parallel() + + store := &Store{} + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `SELECT * FROM demo WHERE`) + require.Error(t, err) + + classifiedErr := store.ClassifyError(err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnknown, sqlErr.Reason) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) +} diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index a93ed3fd72..c936dfdc43 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. @@ -13,8 +14,14 @@ import ( // Store is the SQLite implementation of the WalletStore interface. type Store struct { - db *sql.DB + // db is the shared SQLite connection pool. + db *sql.DB + + // queries executes SQLite statements on db. queries *sqlc.Queries + + // runtimeStats tracks shared runtime counters and unhealthy state. + runtimeStats dbruntime.Stats } // NewStore creates a new SQLite-based WalletStore. It handles the full From 48cb5077fbce74d8335a5f67523ab586ccbf38d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:49:33 +0800 Subject: [PATCH 255/695] db/sqlite: route write paths through runtime Switch sqlite mutating store operations to the package-local runtime write helper so transaction execution, SQL error classification, and ambiguous commit handling stay centralized. --- wallet/internal/db/sqlite/accounts.go | 18 ++++++++------ wallet/internal/db/sqlite/addresses.go | 4 ++-- wallet/internal/db/sqlite/store.go | 13 ---------- wallet/internal/db/sqlite/txstore_createtx.go | 14 +++++------ wallet/internal/db/sqlite/txstore_deletetx.go | 2 +- .../db/sqlite/txstore_invalidateunmined.go | 2 +- wallet/internal/db/sqlite/txstore_rollback.go | 2 +- wallet/internal/db/sqlite/txstore_updatetx.go | 2 +- .../db/sqlite/utxostore_leaseoutput.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 2 +- wallet/internal/db/sqlite/wallet.go | 24 ++++++++++--------- 11 files changed, 39 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index b4507f8bcf..1a193e22eb 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -41,11 +41,13 @@ func (s *Store) ListAccounts(ctx context.Context, func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := accountRenameQueries{q: s.queries} + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + renameQueries := accountRenameQueries{q: qtx} - return db.RenameAccountByQuery( - ctx, params, renameQueries.byNumber, renameQueries.byName, - ) + return db.RenameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) + }) } // CreateDerivedAccount creates a new derived account with the given name and @@ -61,7 +63,7 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -89,7 +91,9 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", + db.ErrMaxAccountNumberReached, err, + ) } info = db.BuildAccountInfo( @@ -145,7 +149,7 @@ func (s *Store) CreateImportedAccount(ctx context.Context, var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 32a293088a..1662e6e10b 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -108,7 +108,7 @@ func (s *Store) NewDerivedAddress(ctx context.Context, } return db.NewDerivedAddressWithTx( - ctx, params, s.ExecuteTx, adapters, deriveFn, + ctx, params, s.execWrite, adapters, deriveFn, ) } @@ -134,7 +134,7 @@ func (s *Store) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAt, } - return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) } // getAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index c936dfdc43..09acdc32d1 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -88,16 +88,3 @@ func (s *Store) Close() error { return nil } - -// ExecuteTx executes a function within a database transaction. The function -// receives a transactional query executor and should perform all database -// operations using it. The transaction will be automatically committed on -// success or rolled back on error. -func (s *Store) ExecuteTx(ctx context.Context, - fn func(*sqlc.Queries) error) error { - - return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { - qtx := s.queries.WithTx(tx) - return fn(qtx) - }) -} diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 120f658a7f..b1f811174d 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -15,12 +15,12 @@ import ( // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned // credits, and any spend edges created by its inputs. // -// The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// spent-parent markers, and any required invalidation are either committed -// together or not at all. Received timestamps are normalized to UTC before -// Insert. When the wallet already stores the same unmined transaction hash, -// CreateTx may promote that existing row to confirmed state instead of -// inserting a duplicate. +// The full write runs inside the shared write helper so the transaction row, +// created UTXOs, spent-parent markers, and any required invalidation are +// either committed together or not at all. Received timestamps are normalized +// to UTC before Insert. When the wallet already stores the same unmined +// transaction hash, CreateTx may promote that existing row to confirmed state +// instead of inserting a duplicate. func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { @@ -29,7 +29,7 @@ func (s *Store) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 0fc03879b3..436d5d9d79 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -21,7 +21,7 @@ import ( func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 569fc4b790..ff18f35e61 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -16,7 +16,7 @@ import ( func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index 6dcf07bb63..7c4b0de0f7 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -16,7 +16,7 @@ import ( func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 43f202dfa2..69b1e237e1 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -20,7 +20,7 @@ import ( func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 1be9ccb8a7..502fbff245 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -21,7 +21,7 @@ func (s *Store) LeaseOutput(ctx context.Context, var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index e2a453c3d0..19f9533523 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -19,7 +19,7 @@ import ( func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 0e3f29b6b2..e1d6506169 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -23,7 +23,7 @@ func (s *Store) CreateWallet(ctx context.Context, var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -196,7 +196,7 @@ func (s *Store) IterWallets(ctx context.Context, func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists( @@ -272,17 +272,19 @@ func (s *Store) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) - if err != nil { - return fmt.Errorf("update wallet secrets: %w", err) - } + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + rowsAffected, err := qtx.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } - if rowsAffected == 0 { - return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, db.ErrWalletNotFound) - } + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, db.ErrWalletNotFound) + } - return nil + return nil + }) } // walletRowParams holds the parameters needed to build a From f17b01fa597e69e213dc2371abd5f4ac47fe7698 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:49:56 +0800 Subject: [PATCH 256/695] db/sqlite: route read paths through runtime Switch sqlite read-only store operations to the package-local runtime read helper so retry policy and SQL error classification stay centralized without direct query plumbing in each method. --- wallet/internal/db/sqlite/accounts.go | 43 +++++-- wallet/internal/db/sqlite/address_types.go | 40 +++++- wallet/internal/db/sqlite/addresses.go | 65 +++++++--- wallet/internal/db/sqlite/txstore_gettx.go | 40 ++++-- wallet/internal/db/sqlite/txstore_listtxns.go | 31 ++++- .../internal/db/sqlite/utxostore_balance.go | 37 ++++-- .../internal/db/sqlite/utxostore_getutxo.go | 43 ++++--- .../db/sqlite/utxostore_listleasedoutputs.go | 49 ++++--- .../internal/db/sqlite/utxostore_listutxos.go | 43 ++++--- wallet/internal/db/sqlite/wallet.go | 120 +++++++++++------- 10 files changed, 349 insertions(+), 162 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 1a193e22eb..50825e5d3b 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -17,11 +17,24 @@ var _ db.AccountStore = (*Store)(nil) func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := accountGetQueries{q: s.queries} + var account *db.AccountInfo - return db.GetAccountByQuery( - ctx, query, getQueries.byNumber, getQueries.byName, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getQueries := accountGetQueries{q: q} + + var err error + + account, err = db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) + + return err + }) + if err != nil { + return nil, err + } + + return account, nil } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -29,11 +42,25 @@ func (s *Store) GetAccount(ctx context.Context, func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := accountListQueries{q: s.queries} + var accounts []db.AccountInfo - return db.ListAccountsByQuery( - ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + listQueries := accountListQueries{q: q} + + var err error + + accounts, err = db.ListAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, + listQueries.all, + ) + + return err + }) + if err != nil { + return nil, err + } + + return accounts, nil } // RenameAccount changes the name of an account. The account can be identified diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index ab12a86503..56efbad6bc 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -28,9 +28,22 @@ func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { - return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, - ) + var infos []db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + infos, err = db.ListAddressTypes( + ctx, q.ListAddressTypes, addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return nil, err + } + + return infos, nil } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -38,8 +51,21 @@ func (s *Store) ListAddressTypes(ctx context.Context) ( func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { - return db.GetAddressTypeByID( - ctx, s.queries.GetAddressTypeByID, int64(id), id, - addressTypeRowToInfo, - ) + var info db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + info, err = db.GetAddressTypeByID( + ctx, q.GetAddressTypeByID, int64(id), id, + addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return db.AddressTypeInfo{}, err + } + + return info, nil } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 1662e6e10b..f7f93f31b2 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -19,19 +19,32 @@ var _ db.AddressStore = (*Store)(nil) func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, - q db.GetAddressQuery) (*db.AddressInfo, error) { - - return db.GetAddress( - ctx, s.queries.GetAddressByScriptPubKey, - sqlc.GetAddressByScriptPubKeyParams{ - WalletID: int64(q.WalletID), - ScriptPubKey: q.ScriptPubKey, - }, addressRowToInfo, - ) + var info *db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getByScript := func(ctx context.Context, + query db.GetAddressQuery) (*db.AddressInfo, error) { + + return db.GetAddress( + ctx, q.GetAddressByScriptPubKey, + sqlc.GetAddressByScriptPubKeyParams{ + WalletID: int64(query.WalletID), + ScriptPubKey: query.ScriptPubKey, + }, addressRowToInfo, + ) + } + + var err error + + info, err = db.GetAddressByQuery(ctx, query, getByScript) + + return err + }) + if err != nil { + return nil, err } - return db.GetAddressByQuery(ctx, query, getByScript) + return info, nil } // ListAddresses returns a page of addresses matching the given query. @@ -42,7 +55,15 @@ func (s *Store) ListAddresses(ctx context.Context, return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } - items, err := listAddressesByAccount(ctx, s.queries, query) + var items []db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + items, err = listAddressesByAccount(ctx, q, query) + + return err + }) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -81,9 +102,23 @@ func (s *Store) GetAddressSecret(ctx context.Context, ) } - return db.GetAddressSecret( - ctx, getSecret, query, addressSecretRowToSecret, - ) + var secret *db.AddressSecret + + err := s.execRead(ctx, func(_ *sqlc.Queries) error { + var err error + + secret, err = db.GetAddressSecret( + ctx, getSecret, query, + addressSecretRowToSecret, + ) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil } // NewDerivedAddress creates a new address for a given account and key diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 9870a85772..2c3a6d56e1 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -18,24 +18,36 @@ import ( func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { - row, err := s.queries.GetTransactionByHash( - ctx, sqlc.GetTransactionByHashParams{ - WalletID: int64(query.WalletID), - TxHash: query.Txid[:], - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) + var info *db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("tx %s: %w", + query.Txid, db.ErrTxNotFound) + } + + return fmt.Errorf("get tx: %w", err) } - return nil, fmt.Errorf("get tx: %w", err) + info, err = txInfoFromRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) + + return err + }) + if err != nil { + return nil, err } - return txInfoFromRow( - row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, - row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, - ) + return info, nil } // txInfoFromRow converts one normalized sqlite query row into the public diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index 9581a9dfc5..a5fe9118c9 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -18,11 +18,28 @@ import ( func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { - if query.UnminedOnly { - return s.listTxnsWithoutBlock(ctx, query.WalletID) + var txns []db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + if query.UnminedOnly { + var err error + + txns, err = s.listTxnsWithoutBlock(ctx, q, query.WalletID) + + return err + } + + var err error + + txns, err = s.listConfirmedTxns(ctx, q, query) + + return err + }) + if err != nil { + return nil, err } - return s.listConfirmedTxns(ctx, query) + return txns, nil } // listTxnsWithoutBlock loads every transaction row that currently has no @@ -30,9 +47,9 @@ func (s *Store) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *Store) listTxnsWithoutBlock(ctx context.Context, - walletID uint32) ([]db.TxInfo, error) { + q *sqlc.Queries, walletID uint32) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + rows, err := q.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } @@ -56,9 +73,9 @@ func (s *Store) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. func (s *Store) listConfirmedTxns(ctx context.Context, - query db.ListTxnsQuery) ([]db.TxInfo, error) { + q *sqlc.Queries, query db.ListTxnsQuery) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsByHeightRange( + rows, err := q.ListTransactionsByHeightRange( ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: int64(query.StartHeight), diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index ce432db4f5..e23f076bcb 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -16,20 +16,33 @@ func (s *Store) Balance(ctx context.Context, nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ - NowUtc: nowUTC, - WalletID: int64(params.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(params.Account), - MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: db.NullableInt32ToSQLInt64(params.CoinbaseMaturity), + var result db.BalanceResult + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + balance, err := q.Balance(ctx, sqlc.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt64( + params.CoinbaseMaturity, + ), + }) + if err != nil { + return fmt.Errorf("balance: %w", err) + } + + result = db.BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + } + + return nil }) if err != nil { - return db.BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, err } - return db.BalanceResult{ - Total: btcutil.Amount(balance.TotalBalance), - Locked: btcutil.Amount(balance.LockedBalance), - }, nil + return result, nil } diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index c470041807..24941e8624 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -18,26 +18,37 @@ import ( func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { - row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlc.GetUtxoByOutpointParams{ - WalletID: int64(query.WalletID), - TxHash: query.OutPoint.Hash[:], - OutputIndex: int64(query.OutPoint.Index), - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - db.ErrUtxoNotFound) + var utxo *db.UtxoInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetUtxoByOutpoint( + ctx, sqlc.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: int64(query.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("utxo %s: %w", query.OutPoint, + db.ErrUtxoNotFound) + } + + return fmt.Errorf("get utxo: %w", err) } - return nil, fmt.Errorf("get utxo: %w", err) + utxo, err = utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + + return err + }) + if err != nil { + return nil, err } - return utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + return utxo, nil } // utxoInfoFromRow converts one normalized sqlite query row into the diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index b7a6317dcb..5a81ff91e7 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -15,31 +15,40 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, nowUTC := time.Now().UTC() - rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlc.ListActiveUtxoLeasesParams{ - WalletID: int64(walletID), - NowUtc: nowUTC, - }, - ) - if err != nil { - return nil, fmt.Errorf("list active utxo leases: %w", err) - } - - leases := make([]db.LeasedOutput, len(rows)) - for i, row := range rows { - outputIndex, err := db.Int64ToUint32(row.OutputIndex) + var leases []db.LeasedOutput + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListActiveUtxoLeases( + ctx, sqlc.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) if err != nil { - return nil, fmt.Errorf("lease output index: %w", err) + return fmt.Errorf("list active utxo leases: %w", err) } - lease, err := db.BuildLeasedOutput( - row.TxHash, outputIndex, row.LockID, row.ExpiresAt, - ) - if err != nil { - return nil, err + leases = make([]db.LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := db.Int64ToUint32(row.OutputIndex) + if err != nil { + return fmt.Errorf("lease output index: %w", err) + } + + lease, err := db.BuildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return err + } + + leases[i] = *lease } - leases[i] = *lease + return nil + }) + if err != nil { + return nil, err } return leases, nil diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 2fa7e94e88..f02f676a8f 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -15,27 +15,36 @@ import ( func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, sqlc.ListUtxosParams{ - WalletID: int64(query.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(query.Account), - MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), - }) - if err != nil { - return nil, fmt.Errorf("list utxos: %w", err) - } + var utxos []db.UtxoInfo - utxos := make([]db.UtxoInfo, len(rows)) - for i, row := range rows { - utxo, err := utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListUtxos(ctx, sqlc.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), + }) if err != nil { - return nil, err + return fmt.Errorf("list utxos: %w", err) + } + + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return err + } + + utxos[i] = *utxo } - utxos[i] = *utxo + return nil + }) + if err != nil { + return nil, err } return utxos, nil diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index e1d6506169..a5d37a1e96 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -117,30 +117,41 @@ func (s *Store) CreateWallet(ctx context.Context, func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { - row, err := s.queries.GetWalletByName(ctx, name) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("wallet %q: %w", name, - db.ErrWalletNotFound) + var info *db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("wallet %q: %w", name, + db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet: %w", err) } - return nil, fmt.Errorf("get wallet: %w", err) - } + info, err = buildWalletInfo(walletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) - return buildWalletInfo(walletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + return err }) + if err != nil { + return nil, err + } + + return info, nil } // ListWallets returns a page of wallets matching the given query. @@ -151,23 +162,29 @@ func (s *Store) ListWallets(ctx context.Context, return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } - rows, err := s.queries.ListWallets( - ctx, listWalletsParams(query.Page), - ) - if err != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: %w", err) - } + var items []db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListWallets(ctx, listWalletsParams(query.Page)) + if err != nil { + return fmt.Errorf("list wallets page: %w", err) + } + + items = make([]db.WalletInfo, len(rows)) + for i, row := range rows { + item, errMap := listWalletRowToInfo(row) + if errMap != nil { + return fmt.Errorf("list wallets page: map row: %w", + errMap) + } - items := make([]db.WalletInfo, len(rows)) - for i, row := range rows { - item, errMap := listWalletRowToInfo(row) - if errMap != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: map row: %w", errMap) + items[i] = *item } - items[i] = *item + return nil + }) + if err != nil { + return page.Result[db.WalletInfo, uint32]{}, err } result := page.BuildResult( @@ -241,23 +258,34 @@ func (s *Store) UpdateWallet(ctx context.Context, func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, db.ErrWalletNotFound) + var encrypted []byte + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + secrets, err := q.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("secrets for wallet %d: %w", + walletID, db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet secrets: %w", err) } - return nil, fmt.Errorf("get wallet secrets: %w", err) - } + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + db.ErrSecretNotFound) + } - if len(secrets.EncryptedMasterHdPrivKey) == 0 { - return nil, fmt.Errorf( - "encrypted master privkey for wallet %d: %w", walletID, - db.ErrSecretNotFound) + encrypted = secrets.EncryptedMasterHdPrivKey + + return nil + }) + if err != nil { + return nil, err } - return secrets.EncryptedMasterHdPrivKey, nil + return encrypted, nil } // UpdateWalletSecrets updates the secrets for the wallet. From 087e1d1fbdc469448599e327d6433f9f8d75255e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:54:41 +0800 Subject: [PATCH 257/695] db/pg: add runtime store helpers Add postgres-local runtime adapters and store state so later call-site changes can share read retry, SQL error classification, unhealthy-store tracking, and write execution semantics. --- wallet/internal/db/pg/runtime.go | 125 ++++++++++++++++++++++++++ wallet/internal/db/pg/runtime_test.go | 99 ++++++++++++++++++++ wallet/internal/db/pg/store.go | 9 +- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/pg/runtime.go create mode 100644 wallet/internal/db/pg/runtime_test.go diff --git a/wallet/internal/db/pg/runtime.go b/wallet/internal/db/pg/runtime.go new file mode 100644 index 0000000000..fbf7469ae6 --- /dev/null +++ b/wallet/internal/db/pg/runtime.go @@ -0,0 +1,125 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +var ( + // Compile-time interface checks keep Store aligned with the shared runtime + // hook contracts. + _ dbruntime.ReadHooks = (*Store)(nil) + _ dbruntime.WriteHooks = (*Store)(nil) +) + +// Default PostgreSQL read retry settings. +const ( + // defaultReadMaxAttempts keeps retries bounded to one initial attempt plus + // up to two retries for short-lived serialization or lock failures. + defaultReadMaxAttempts = 3 + + // defaultReadBaseDelay starts backoff at 10 ms so brief transient conflicts + // can clear without noticeably delaying callers. + defaultReadBaseDelay = 10 * time.Millisecond + + // defaultReadMaxDelay caps retry backoff at 100 ms so repeated transient + // errors do not stall wallet operations for too long. + defaultReadMaxDelay = 100 * time.Millisecond +) + +// execRead executes one PostgreSQL read operation through the shared runtime +// helper. +func (s *Store) execRead(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Read( + ctx, s, s.queries, defaultReadConfig(), + func(_ context.Context, q *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(q) + }, + ) + + return err +} + +// execWrite executes one PostgreSQL write operation through the shared runtime +// helper. +func (s *Store) execWrite(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Write( + ctx, s, + func(tx *sql.Tx) *sqlc.Queries { + return s.queries.WithTx(tx) + }, + func(qtx *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(qtx) + }, + ) + + return err +} + +// defaultReadConfig returns the PostgreSQL read retry policy. +func defaultReadConfig() dbruntime.ReadConfig { + // TODO(yy): make it configurable. + return dbruntime.ReadConfig{ + MaxAttempts: defaultReadMaxAttempts, + BaseDelay: defaultReadBaseDelay, + MaxDelay: defaultReadMaxDelay, + } +} + +// CheckHealthy reports whether a prior fatal SQL backend error poisoned the +// store. +func (s *Store) CheckHealthy() error { + return s.runtimeStats.CheckHealthy() +} + +// ClassifyError normalizes one PostgreSQL backend error into the shared SQL +// error model while preserving ordinary domain errors unchanged. +func (s *Store) ClassifyError(err error) error { + return dberr.Normalize(dberr.BackendPostgres, mapErr, err) +} + +// RecordError records one classified PostgreSQL backend error and marks the +// store unhealthy after fatal failures. +func (s *Store) RecordError(err error) { + s.runtimeStats.RecordError(err) +} + +// RecordRetryAttempt records one PostgreSQL read retry attempt. +func (s *Store) RecordRetryAttempt() { + s.runtimeStats.RecordRetryAttempt() +} + +// RecordRetrySuccess records one successful PostgreSQL read retry outcome. +func (s *Store) RecordRetrySuccess() { + s.runtimeStats.RecordRetrySuccess() +} + +// RecordRetryExhausted records one exhausted PostgreSQL read retry sequence. +func (s *Store) RecordRetryExhausted() { + s.runtimeStats.RecordRetryExhausted() +} + +// RecordAmbiguousTxCommit records one PostgreSQL commit failure with unknown +// outcome. +func (s *Store) RecordAmbiguousTxCommit() { + s.runtimeStats.RecordAmbiguousTxCommit() +} + +// RawDB returns the PostgreSQL database handle used by shared runtime writes. +func (s *Store) RawDB() *sql.DB { + return s.db +} + +// StatsSnapshot returns the current PostgreSQL runtime counters. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return s.runtimeStats.Snapshot(dberr.BackendPostgres) +} diff --git a/wallet/internal/db/pg/runtime_test.go b/wallet/internal/db/pg/runtime_test.go new file mode 100644 index 0000000000..61d3971e38 --- /dev/null +++ b/wallet/internal/db/pg/runtime_test.go @@ -0,0 +1,99 @@ +package pg + +import ( + "database/sql" + "io" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// TestClassifyErrorReturnsOriginalErrors verifies that PostgreSQL +// classification preserves domain and already-classified errors unchanged. +func TestClassifyErrorReturnsOriginalErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + errDup := dberr.NewSQLError( + dberr.BackendPostgres, + dberr.ReasonConstraint, + codeUniqueViolation, + sql.ErrTxDone, + ) + tests := []struct { + name string + err error + }{ + {name: "wallet not found", err: db.ErrWalletNotFound}, + {name: "tx not found", err: db.ErrTxNotFound}, + {name: "generic error", err: io.ErrClosedPipe}, + {name: "existing sql error", err: errDup}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Same(t, test.err, store.ClassifyError(test.err)) + }) + } +} + +// TestClassifyErrorTransportError verifies that PostgreSQL transport failures +// are classified as shared unavailable SQL errors. +func TestClassifyErrorTransportError(t *testing.T) { + t.Parallel() + + store := &Store{} + classifiedErr := store.ClassifyError(&pgconn.ConnectError{}) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) +} + +// TestClassifyErrorBackendErrors verifies that PostgreSQL backend-native +// errors stay wrapped as shared SQL errors for both known and unknown codes. +func TestClassifyErrorBackendErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + tests := []struct { + name string + err error + wantReason dberr.Reason + }{ + { + name: "known code", + err: &pgconn.PgError{ + Code: codeUniqueViolation, + Message: "duplicate key", + }, + wantReason: dberr.ReasonConstraint, + }, + { + name: "unknown code", + err: &pgconn.PgError{ + Code: "99999", + Message: "unknown error", + }, + wantReason: dberr.ReasonUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + classifiedErr := store.ClassifyError(test.err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, test.wantReason, sqlErr.Reason) + require.Equal(t, dberr.BackendPostgres, sqlErr.Backend) + }) + } +} diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 4b734d84c0..d87a7884de 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. @@ -14,8 +15,14 @@ import ( // Store is the PostgreSQL implementation of the // WalletStore interface. type Store struct { - db *sql.DB + // db is the shared PostgreSQL connection pool. + db *sql.DB + + // queries executes PostgreSQL statements on db. queries *sqlc.Queries + + // runtimeStats tracks shared runtime counters and unhealthy state. + runtimeStats dbruntime.Stats } // NewStore creates a new PostgreSQL-based WalletStore. It handles From 772fcb2533c1033a19c6fe2f7df6c808bfacb931 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:55:01 +0800 Subject: [PATCH 258/695] db/pg: route write paths through runtime Switch postgres mutating store operations to the package-local runtime write helper so transaction execution, SQL error classification, and ambiguous commit handling stay centralized. --- wallet/internal/db/pg/accounts.go | 18 ++++++++------ wallet/internal/db/pg/addresses.go | 4 ++-- wallet/internal/db/pg/store.go | 13 ---------- wallet/internal/db/pg/txstore_createtx.go | 14 +++++------ wallet/internal/db/pg/txstore_deletetx.go | 2 +- .../db/pg/txstore_invalidateunmined.go | 2 +- wallet/internal/db/pg/txstore_rollback.go | 2 +- wallet/internal/db/pg/txstore_updatetx.go | 2 +- .../internal/db/pg/utxostore_leaseoutput.go | 2 +- .../internal/db/pg/utxostore_releaseoutput.go | 2 +- wallet/internal/db/pg/wallet.go | 24 ++++++++++--------- 11 files changed, 39 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 180a5efeec..2ef6b44ff9 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -41,11 +41,13 @@ func (s *Store) ListAccounts(ctx context.Context, func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := accountRenameQueries{q: s.queries} + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + renameQueries := accountRenameQueries{q: qtx} - return db.RenameAccountByQuery( - ctx, params, renameQueries.byNumber, renameQueries.byName, - ) + return db.RenameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) + }) } // CreateDerivedAccount creates a new derived account with the given name and @@ -61,7 +63,7 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -99,7 +101,9 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", + db.ErrMaxAccountNumberReached, err, + ) } info = db.BuildAccountInfo( @@ -125,7 +129,7 @@ func (s *Store) CreateImportedAccount(ctx context.Context, var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 74bc32b469..d54e0dce4f 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -108,7 +108,7 @@ func (s *Store) NewDerivedAddress(ctx context.Context, } return db.NewDerivedAddressWithTx( - ctx, params, s.ExecuteTx, adapters, deriveFn, + ctx, params, s.execWrite, adapters, deriveFn, ) } @@ -134,7 +134,7 @@ func (s *Store) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAt, } - return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) } // getAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index d87a7884de..7c373256da 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -82,16 +82,3 @@ func (s *Store) Close() error { return nil } - -// ExecuteTx executes a function within a database transaction. The function -// receives a transactional query executor and should perform all database -// operations using it. The transaction will be automatically committed on -// success or rolled back on error. -func (s *Store) ExecuteTx(ctx context.Context, - fn func(*sqlc.Queries) error) error { - - return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { - qtx := s.queries.WithTx(tx) - return fn(qtx) - }) -} diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 2f880d5882..4cc96388be 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -15,12 +15,12 @@ import ( // CreateTx atomically records a wallet-scoped transaction row, its // wallet-owned credits, and any spend edges created by its inputs. // -// The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// spent-parent markers, and any required invalidation are either committed -// together or not at all. Received timestamps are normalized to UTC before -// Insert. When the wallet already stores the same unmined transaction hash, -// CreateTx may promote that existing row to confirmed state instead of -// inserting a duplicate. +// The full write runs inside the shared write helper so the transaction row, +// created UTXOs, spent-parent markers, and any required invalidation are +// either committed together or not at all. Received timestamps are normalized +// to UTC before Insert. When the wallet already stores the same unmined +// transaction hash, CreateTx may promote that existing row to confirmed state +// instead of inserting a duplicate. func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { @@ -29,7 +29,7 @@ func (s *Store) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index e6e8500399..5dfab4348a 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -21,7 +21,7 @@ import ( func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index d5fdc9aeac..5648e1d2dc 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -16,7 +16,7 @@ import ( func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index b74108d367..bf72548c55 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -16,7 +16,7 @@ import ( func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index fbb5a87c3b..96b1918d2b 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -20,7 +20,7 @@ import ( func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index d0f685e270..9443e69ba6 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -21,7 +21,7 @@ func (s *Store) LeaseOutput(ctx context.Context, var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 4555f3b932..12d66b6dc3 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -19,7 +19,7 @@ import ( func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index e72d5c3662..346bade14d 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -23,7 +23,7 @@ func (s *Store) CreateWallet(ctx context.Context, var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -194,7 +194,7 @@ func (s *Store) IterWallets(ctx context.Context, func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists(ctx, qtx, params.SyncedTo) @@ -271,17 +271,19 @@ func (s *Store) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) - if err != nil { - return fmt.Errorf("update wallet secrets: %w", err) - } + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + rowsAffected, err := qtx.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } - if rowsAffected == 0 { - return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, db.ErrWalletNotFound) - } + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, db.ErrWalletNotFound) + } - return nil + return nil + }) } // walletRowParams holds the parameters needed to build a WalletInfo From 2016251b34538ba2983a8176f82b32032e439226 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:55:26 +0800 Subject: [PATCH 259/695] db/pg: route read paths through runtime Switch postgres read-only store operations to the package-local runtime read helper so retry policy and SQL error classification stay centralized without direct query plumbing in each method. --- wallet/internal/db/pg/accounts.go | 43 +++++-- wallet/internal/db/pg/address_types.go | 40 ++++-- wallet/internal/db/pg/addresses.go | 64 +++++++--- wallet/internal/db/pg/txstore_gettx.go | 40 +++--- wallet/internal/db/pg/txstore_listtxns.go | 31 +++-- wallet/internal/db/pg/utxostore_balance.go | 37 ++++-- wallet/internal/db/pg/utxostore_getutxo.go | 43 ++++--- .../db/pg/utxostore_listleasedoutputs.go | 49 +++++--- wallet/internal/db/pg/utxostore_listutxos.go | 33 +++-- wallet/internal/db/pg/wallet.go | 118 +++++++++++------- 10 files changed, 343 insertions(+), 155 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 2ef6b44ff9..ac90985add 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -17,11 +17,24 @@ var _ db.AccountStore = (*Store)(nil) func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := accountGetQueries{q: s.queries} + var account *db.AccountInfo - return db.GetAccountByQuery( - ctx, query, getQueries.byNumber, getQueries.byName, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getQueries := accountGetQueries{q: q} + + var err error + + account, err = db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) + + return err + }) + if err != nil { + return nil, err + } + + return account, nil } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -29,11 +42,25 @@ func (s *Store) GetAccount(ctx context.Context, func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := accountListQueries{q: s.queries} + var accounts []db.AccountInfo - return db.ListAccountsByQuery( - ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + listQueries := accountListQueries{q: q} + + var err error + + accounts, err = db.ListAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, + listQueries.all, + ) + + return err + }) + if err != nil { + return nil, err + } + + return accounts, nil } // RenameAccount changes the name of an account. The account can be identified diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 919bfb6c98..420f5000f3 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -26,9 +26,22 @@ func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { - return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, - ) + var infos []db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + infos, err = db.ListAddressTypes( + ctx, q.ListAddressTypes, addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return nil, err + } + + return infos, nil } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -36,8 +49,21 @@ func (s *Store) ListAddressTypes(ctx context.Context) ( func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { - return db.GetAddressTypeByID( - ctx, s.queries.GetAddressTypeByID, int16(id), id, - addressTypeRowToInfo, - ) + var info db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + info, err = db.GetAddressTypeByID( + ctx, q.GetAddressTypeByID, int16(id), id, + addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return db.AddressTypeInfo{}, err + } + + return info, nil } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index d54e0dce4f..b82e2dce97 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -19,19 +19,32 @@ var _ db.AddressStore = (*Store)(nil) func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, - q db.GetAddressQuery) (*db.AddressInfo, error) { - - return db.GetAddress( - ctx, s.queries.GetAddressByScriptPubKey, - sqlc.GetAddressByScriptPubKeyParams{ - ScriptPubKey: q.ScriptPubKey, - WalletID: int64(q.WalletID), - }, addressRowToInfo, - ) + var info *db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getByScript := func(ctx context.Context, + query db.GetAddressQuery) (*db.AddressInfo, error) { + + return db.GetAddress( + ctx, q.GetAddressByScriptPubKey, + sqlc.GetAddressByScriptPubKeyParams{ + ScriptPubKey: query.ScriptPubKey, + WalletID: int64(query.WalletID), + }, addressRowToInfo, + ) + } + + var err error + + info, err = db.GetAddressByQuery(ctx, query, getByScript) + + return err + }) + if err != nil { + return nil, err } - return db.GetAddressByQuery(ctx, query, getByScript) + return info, nil } // ListAddresses returns a page of addresses matching the given query. @@ -42,7 +55,15 @@ func (s *Store) ListAddresses(ctx context.Context, return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } - items, err := listAddressesByAccount(ctx, s.queries, query) + var items []db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + items, err = listAddressesByAccount(ctx, q, query) + + return err + }) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -81,9 +102,22 @@ func (s *Store) GetAddressSecret(ctx context.Context, ) } - return db.GetAddressSecret( - ctx, getSecret, query, addressSecretRowToSecret, - ) + var secret *db.AddressSecret + + err := s.execRead(ctx, func(_ *sqlc.Queries) error { + var err error + + secret, err = db.GetAddressSecret( + ctx, getSecret, query, addressSecretRowToSecret, + ) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil } // NewDerivedAddress creates a new address for a given account and key diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index c117d8fcef..dabe153d80 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -18,24 +18,36 @@ import ( func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { - row, err := s.queries.GetTransactionByHash( - ctx, sqlc.GetTransactionByHashParams{ - WalletID: int64(query.WalletID), - TxHash: query.Txid[:], - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) + var info *db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("tx %s: %w", + query.Txid, db.ErrTxNotFound) + } + + return fmt.Errorf("get tx: %w", err) } - return nil, fmt.Errorf("get tx: %w", err) + info, err = txInfoFromRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) + + return err + }) + if err != nil { + return nil, err } - return txInfoFromRow( - row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, - row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, - ) + return info, nil } // txInfoFromRow converts one normalized postgres query row into the public diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 222a69b2ea..8372cd1e88 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -18,11 +18,28 @@ import ( func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { - if query.UnminedOnly { - return s.listTxnsWithoutBlock(ctx, query.WalletID) + var txns []db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + if query.UnminedOnly { + var err error + + txns, err = s.listTxnsWithoutBlock(ctx, q, query.WalletID) + + return err + } + + var err error + + txns, err = s.listConfirmedTxns(ctx, q, query) + + return err + }) + if err != nil { + return nil, err } - return s.listConfirmedTxns(ctx, query) + return txns, nil } // listTxnsWithoutBlock loads every transaction row that currently has no @@ -30,9 +47,9 @@ func (s *Store) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *Store) listTxnsWithoutBlock(ctx context.Context, - walletID uint32) ([]db.TxInfo, error) { + q *sqlc.Queries, walletID uint32) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + rows, err := q.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } @@ -56,7 +73,7 @@ func (s *Store) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. func (s *Store) listConfirmedTxns(ctx context.Context, - query db.ListTxnsQuery) ([]db.TxInfo, error) { + q *sqlc.Queries, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) if err != nil { @@ -68,7 +85,7 @@ func (s *Store) listConfirmedTxns(ctx context.Context, return nil, fmt.Errorf("convert end height: %w", err) } - rows, err := s.queries.ListTransactionsByHeightRange( + rows, err := q.ListTransactionsByHeightRange( ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: startHeight, diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 7e88ad5ae0..49f54ab34a 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -16,20 +16,33 @@ func (s *Store) Balance(ctx context.Context, nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ - NowUtc: nowUTC, - WalletID: int64(params.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(params.Account), - MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: db.NullableInt32ToSQLInt32(params.CoinbaseMaturity), + var result db.BalanceResult + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + balance, err := q.Balance(ctx, sqlc.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt32( + params.CoinbaseMaturity, + ), + }) + if err != nil { + return fmt.Errorf("balance: %w", err) + } + + result = db.BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + } + + return nil }) if err != nil { - return db.BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, err } - return db.BalanceResult{ - Total: btcutil.Amount(balance.TotalBalance), - Locked: btcutil.Amount(balance.LockedBalance), - }, nil + return result, nil } diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 4a2b4f8114..b457d7193d 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -23,26 +23,37 @@ func (s *Store) GetUtxo(ctx context.Context, return nil, fmt.Errorf("convert output index: %w", err) } - row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlc.GetUtxoByOutpointParams{ - WalletID: int64(query.WalletID), - TxHash: query.OutPoint.Hash[:], - OutputIndex: outputIndex, - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - db.ErrUtxoNotFound) + var utxo *db.UtxoInfo + + err = s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetUtxoByOutpoint( + ctx, sqlc.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("utxo %s: %w", query.OutPoint, + db.ErrUtxoNotFound) + } + + return fmt.Errorf("get utxo: %w", err) } - return nil, fmt.Errorf("get utxo: %w", err) + utxo, err = utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + + return err + }) + if err != nil { + return nil, err } - return utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + return utxo, nil } // utxoInfoFromRow converts one normalized postgres query row into the public diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 7df070a56b..d3272c5104 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -15,31 +15,40 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, nowUTC := time.Now().UTC() - rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlc.ListActiveUtxoLeasesParams{ - WalletID: int64(walletID), - NowUtc: nowUTC, - }, - ) - if err != nil { - return nil, fmt.Errorf("list active utxo leases: %w", err) - } - - leases := make([]db.LeasedOutput, len(rows)) - for i, row := range rows { - outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) + var leases []db.LeasedOutput + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListActiveUtxoLeases( + ctx, sqlc.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) if err != nil { - return nil, fmt.Errorf("lease output index: %w", err) + return fmt.Errorf("list active utxo leases: %w", err) } - lease, err := db.BuildLeasedOutput( - row.TxHash, outputIndex, row.LockID, row.ExpiresAt, - ) - if err != nil { - return nil, err + leases = make([]db.LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) + if err != nil { + return fmt.Errorf("lease output index: %w", err) + } + + lease, err := db.BuildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return err + } + + leases[i] = *lease } - leases[i] = *lease + return nil + }) + if err != nil { + return nil, err } return leases, nil diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 39de82d47a..6fa3befc4d 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -15,22 +15,31 @@ import ( func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) - if err != nil { - return nil, fmt.Errorf("list utxos: %w", err) - } + var utxos []db.UtxoInfo - utxos := make([]db.UtxoInfo, len(rows)) - for i, row := range rows { - utxo, err := utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListUtxos(ctx, buildListUtxosParams(query)) if err != nil { - return nil, err + return fmt.Errorf("list utxos: %w", err) } - utxos[i] = *utxo + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return err + } + + utxos[i] = *utxo + } + + return nil + }) + if err != nil { + return nil, err } return utxos, nil diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 346bade14d..70b8422f79 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -117,30 +117,41 @@ func (s *Store) CreateWallet(ctx context.Context, func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { - row, err := s.queries.GetWalletByName(ctx, name) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("wallet %q: %w", name, - db.ErrWalletNotFound) + var info *db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("wallet %q: %w", name, + db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet: %w", err) } - return nil, fmt.Errorf("get wallet: %w", err) - } + info, err = buildWalletInfo(walletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) - return buildWalletInfo(walletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + return err }) + if err != nil { + return nil, err + } + + return info, nil } // ListWallets returns a page of wallets matching the given query. @@ -151,21 +162,29 @@ func (s *Store) ListWallets(ctx context.Context, return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } - rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) - if err != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: %w", err) - } + var items []db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListWallets(ctx, listWalletsParams(query.Page)) + if err != nil { + return fmt.Errorf("list wallets page: %w", err) + } + + items = make([]db.WalletInfo, len(rows)) + for i, row := range rows { + item, errMap := listWalletRowToInfo(row) + if errMap != nil { + return fmt.Errorf("list wallets page: map row: %w", + errMap) + } - items := make([]db.WalletInfo, len(rows)) - for i, row := range rows { - item, errMap := listWalletRowToInfo(row) - if errMap != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: map row: %w", errMap) + items[i] = *item } - items[i] = *item + return nil + }) + if err != nil { + return page.Result[db.WalletInfo, uint32]{}, err } result := page.BuildResult( @@ -240,23 +259,34 @@ func (s *Store) UpdateWallet(ctx context.Context, func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, db.ErrWalletNotFound) + var encrypted []byte + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + secrets, err := q.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("secrets for wallet %d: %w", + walletID, db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet secrets: %w", err) } - return nil, fmt.Errorf("get wallet secrets: %w", err) - } + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + db.ErrSecretNotFound) + } - if len(secrets.EncryptedMasterHdPrivKey) == 0 { - return nil, fmt.Errorf( - "encrypted master privkey for wallet %d: %w", walletID, - db.ErrSecretNotFound) + encrypted = secrets.EncryptedMasterHdPrivKey + + return nil + }) + if err != nil { + return nil, err } - return secrets.EncryptedMasterHdPrivKey, nil + return encrypted, nil } // UpdateWalletSecrets updates the secrets for the wallet. From e2f319abc59b50ee7a0b8fbd268637c1a6dea51e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Apr 2026 19:11:56 +0800 Subject: [PATCH 260/695] db: drop legacy tx helper Remove the old shared transaction helper now that both SQL backends use the runtime write path directly, keeping transaction policy in one place. --- wallet/internal/db/tx.go | 41 ---------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 wallet/internal/db/tx.go diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go deleted file mode 100644 index 3a66af29e8..0000000000 --- a/wallet/internal/db/tx.go +++ /dev/null @@ -1,41 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" -) - -// ExecInTx executes a function within a database transaction. It handles -// the transaction lifecycle: begin, commit, and rollback on error. -// -// This is a helper function used by the public ExecuteTx methods on -// pg.Store and sqlite.Store. It guarantees that the transaction -// will be either committed (on success) or rolled back (on error or panic). -func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin tx: %w", err) - } - - // Rollback can be called safely even when the transaction is already - // closed. If the transaction commits, this call does nothing. If the - // rollback fails because of a connection issue, it is still fine since - // the transaction was never committed, and the database remains - // unchanged. - defer func() { - _ = tx.Rollback() - }() - - err = fn(tx) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("commit tx: %w", err) - } - - return nil -} From 2a1e77bd4672d9f5bc91ee08062876e425eb3241 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 20 Apr 2026 20:35:55 +0800 Subject: [PATCH 261/695] db/itest: verify SQL error classification Exercise duplicate constraint failures against real SQLite and PostgreSQL stores so the shared SQL error wrapper is validated end to end. The itests also verify that constraint failures increment the runtime SQL stats counters while validation errors leave those counters unchanged. --- .../internal/db/itest/account_store_test.go | 21 +++++++++- .../internal/db/itest/fixtures_common_test.go | 2 + wallet/internal/db/itest/fixtures_pg_test.go | 20 ++++++++++ .../internal/db/itest/fixtures_sqlite_test.go | 20 ++++++++++ wallet/internal/db/itest/wallet_store_test.go | 39 +++++++++++++++---- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index db06fb306a..ca9e2307b9 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -153,10 +153,29 @@ func TestCreateDerivedAccountDuplicateName(t *testing.T) { _, err := store.CreateDerivedAccount(t.Context(), params) require.NoError(t, err) + before := store.StatsSnapshot() + // Attempt to create second account with same name in same scope. _, err = store.CreateDerivedAccount(t.Context(), params) require.Error(t, err) - require.ErrorContains(t, err, "constraint") + requireConstraintSQLError(t, err) + + after := store.StatsSnapshot() + require.Equal(t, before.Unhealthy, after.Unhealthy) + require.Equal(t, before.RetryAttempts, after.RetryAttempts) + require.Equal(t, before.RetrySuccesses, after.RetrySuccesses) + require.Equal(t, before.RetryExhausted, after.RetryExhausted) + require.Equal(t, before.AmbiguousTxCommits, after.AmbiguousTxCommits) + require.Equal(t, before.Errors.Backend, after.Errors.Backend) + require.Equal(t, before.Errors.TotalErrs+1, after.Errors.TotalErrs) + require.Equal( + t, + before.Errors.PermanentErrs+1, + after.Errors.PermanentErrs, + ) + require.Equal(t, before.Errors.Constraint+1, after.Errors.Constraint) + require.Equal(t, before.Errors.TransientErrs, after.Errors.TransientErrs) + require.Equal(t, before.Errors.FatalErrs, after.Errors.FatalErrs) } // TestCreateDerivedAccountSameNameDifferentScopes verifies that accounts with diff --git a/wallet/internal/db/itest/fixtures_common_test.go b/wallet/internal/db/itest/fixtures_common_test.go index 9ad3a2ee97..16babaad25 100644 --- a/wallet/internal/db/itest/fixtures_common_test.go +++ b/wallet/internal/db/itest/fixtures_common_test.go @@ -1,3 +1,5 @@ +//go:build itest + package itest import ( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 9f0a79ce21..e81de26356 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -5,16 +5,36 @@ package itest import ( "context" "database/sql" + "errors" "fmt" "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) +// testBackend returns the SQL backend expected by PostgreSQL itests. +func testBackend() dberr.Backend { + return dberr.BackendPostgres +} + +// requireConstraintSQLError verifies that a real PostgreSQL constraint failure +// reaches callers as the shared SQL error wrapper. +func requireConstraintSQLError(t *testing.T, err error) { + t.Helper() + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, testBackend(), sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.True(t, errors.Is(err, sqlErr)) +} + // CreateBlockFixture inserts a test block into the database and returns it. func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 6901e74208..61d2417dd7 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -5,16 +5,36 @@ package itest import ( "context" "database/sql" + "errors" "fmt" "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) +// testBackend returns the SQL backend expected by SQLite itests. +func testBackend() dberr.Backend { + return dberr.BackendSQLite +} + +// requireConstraintSQLError verifies that a real SQLite constraint failure +// reaches callers as the shared SQL error wrapper. +func requireConstraintSQLError(t *testing.T, err error) { + t.Helper() + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, testBackend(), sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.True(t, errors.Is(err, sqlErr)) +} + // CreateBlockFixture inserts a test block into the database and returns it. func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index b9150e23c4..22f282181c 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -79,16 +79,29 @@ func TestCreateWallet_DuplicateName(t *testing.T) { _, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) + before := store.StatsSnapshot() + // Attempt to create second wallet with same name. _, err = store.CreateWallet(t.Context(), params) require.Error(t, err, "expected error creating duplicate wallet") - - // We still do not normalize this error across database backends, - // and each engine returns its own message. Because of that, - // we only check for the shared parts of the message here. - require.ErrorContains(t, err, "wallets") - require.ErrorContains(t, err, "name") - require.ErrorContains(t, err, "constraint") + requireConstraintSQLError(t, err) + + after := store.StatsSnapshot() + require.Equal(t, before.Unhealthy, after.Unhealthy) + require.Equal(t, before.RetryAttempts, after.RetryAttempts) + require.Equal(t, before.RetrySuccesses, after.RetrySuccesses) + require.Equal(t, before.RetryExhausted, after.RetryExhausted) + require.Equal(t, before.AmbiguousTxCommits, after.AmbiguousTxCommits) + require.Equal(t, before.Errors.Backend, after.Errors.Backend) + require.Equal(t, before.Errors.TotalErrs+1, after.Errors.TotalErrs) + require.Equal( + t, + before.Errors.PermanentErrs+1, + after.Errors.PermanentErrs, + ) + require.Equal(t, before.Errors.Constraint+1, after.Errors.Constraint) + require.Equal(t, before.Errors.TransientErrs, after.Errors.TransientErrs) + require.Equal(t, before.Errors.FatalErrs, after.Errors.FatalErrs) } // TestCreateWallet_Variants tests different wallet types. @@ -156,10 +169,14 @@ func TestGetWallet_NotFound(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() _, err := store.GetWallet(t.Context(), "non-existent-wallet") require.Error(t, err) require.ErrorIs(t, err, db.ErrWalletNotFound) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestListWallets verifies that ListWallets correctly returns wallets and @@ -204,9 +221,13 @@ func TestListWalletsZeroLimit(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() _, err := store.ListWallets(t.Context(), db.ListWalletsQuery{}) require.ErrorIs(t, err, db.ErrInvalidPageLimit) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestListWalletsPagination verifies that ListWallets paginates correctly and @@ -763,6 +784,7 @@ func TestUpdateWallet_NotFound(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() updateParams := db.UpdateWalletParams{ WalletID: 99999, // Non-existent ID. @@ -771,6 +793,9 @@ func TestUpdateWallet_NotFound(t *testing.T) { err := store.UpdateWallet(t.Context(), updateParams) require.Error(t, err) require.ErrorIs(t, err, db.ErrWalletNotFound) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestGetEncryptedHDSeed verifies retrieving the encrypted HD seed. From a52f3da6a8b69a6d8b6ea81f7001ddfcef681ee6 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 21 Apr 2026 22:10:11 -0300 Subject: [PATCH 262/695] wallet: guard nil derived address callbacks --- wallet/internal/db/addresses_common.go | 32 +++++++--- wallet/internal/db/addresses_common_test.go | 63 +++++++++++++++++++ .../internal/db/itest/address_store_test.go | 59 +++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 wallet/internal/db/addresses_common_test.go diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e5ba932de1..6f512137d1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -14,6 +14,16 @@ import ( const DefaultImportedAccountName = "imported" var ( + // errNilAddressDerivationFunc is returned when derived address creation is + // called without a derivation callback. + errNilAddressDerivationFunc = errors.New( + "address derivation callback is nil", + ) + + // errNilDerivedAddressData is returned when the derivation callback reports + // success but does not return any derived address data. + errNilDerivedAddressData = errors.New("derived address data is nil") + // errInvalidOriginID is returned when an origin ID from the database is // outside the valid range [DerivedAccount, ImportedAccount]. In practice, // this should never happen, but it's possible if the database is modified @@ -529,8 +539,7 @@ func derivedAddressInput(ctx context.Context, indexValue, err := getIdx(ctx, accountID) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "get next address index: %w", err) + return 0, 0, 0, nil, fmt.Errorf("get next address index: %w", err) } if indexValue > math.MaxUint32 { @@ -539,20 +548,22 @@ func derivedAddressInput(ctx context.Context, index, err := Int64ToUint32(indexValue) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "address index: %w", err) + return 0, 0, 0, nil, fmt.Errorf("address index: %w", err) } acctID, err := Int64ToUint32(accountID) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "account ID: %w", err) + return 0, 0, 0, nil, fmt.Errorf("account ID: %w", err) } derivedData, err := deriveFn(ctx, acctID, branch, index) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "derive address: %w", err) + return 0, 0, 0, nil, fmt.Errorf("derive address: %w", err) + } + + if derivedData == nil { + return 0, 0, 0, nil, fmt.Errorf("derive address: %w", + errNilDerivedAddressData) } return addrType, branch, index, derivedData.ScriptPubKey, nil @@ -567,6 +578,11 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, adapters DerivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], deriveFn AddressDerivationFunc) (*AddressInfo, error) { + if deriveFn == nil { + return nil, fmt.Errorf("derive address: %w", + errNilAddressDerivationFunc) + } + var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { diff --git a/wallet/internal/db/addresses_common_test.go b/wallet/internal/db/addresses_common_test.go new file mode 100644 index 0000000000..df6f19a2d1 --- /dev/null +++ b/wallet/internal/db/addresses_common_test.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewDerivedAddressWithTxNilDeriveFn verifies that the shared helper +// rejects a missing derivation callback before opening a transaction. +func TestNewDerivedAddressWithTxNilDeriveFn(t *testing.T) { + t.Parallel() + + executed := false + + _, err := NewDerivedAddressWithTx( + t.Context(), NewDerivedAddressParams{}, + func(context.Context, func(struct{}) error) error { + executed = true + + return nil + }, + DerivedAddressAdapters[struct{}, struct{}, struct{}, struct{}]{}, nil, + ) + + require.False(t, executed) + require.ErrorIs(t, err, errNilAddressDerivationFunc) +} + +// TestDerivedAddressInputNilDerivedData verifies that the shared derivation +// path rejects a nil callback result before dereferencing it. +func TestDerivedAddressInputNilDerivedData(t *testing.T) { + t.Parallel() + + params := NewDerivedAddressParams{ + Scope: KeyScopeBIP0084, + } + + deriveFn := func(context.Context, uint32, uint32, + uint32) (*DerivedAddressData, error) { + + var derivedData *DerivedAddressData + + return derivedData, nil + } + + addrType, branch, index, scriptPubKey, err := derivedAddressInput( + t.Context(), params, 1, + func(context.Context, int64) (int64, error) { + return 7, nil + }, + func(context.Context, int64) (int64, error) { + return 11, nil + }, deriveFn, + ) + + require.Zero(t, addrType) + require.Zero(t, branch) + require.Zero(t, index) + require.Nil(t, scriptPubKey) + require.ErrorIs(t, err, errNilDerivedAddressData) +} diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 7678301efa..0d8222c2c5 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1109,6 +1109,65 @@ func TestNewDerivedAddress(t *testing.T) { } } +// TestNewDerivedAddressDerivationGuards verifies that NewDerivedAddress returns +// errors instead of panicking when the derivation callback is nil or returns +// nil derived data. +func TestNewDerivedAddressDerivationGuards(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-derived-guards") + accountName := "derived-guard-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + params := db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + } + + t.Run("nil derive callback", func(t *testing.T) { + var ( + info *db.AddressInfo + err error + ) + + require.NotPanics( + t, func() { + info, err = store.NewDerivedAddress(t.Context(), params, nil) + }, + ) + + require.Nil(t, info) + require.Error(t, err) + }) + + t.Run("nil derived data", func(t *testing.T) { + deriveFn := func(ctx context.Context, accountID uint32, branch uint32, + index uint32) (*db.DerivedAddressData, error) { + + return nil, nil + } + + var ( + info *db.AddressInfo + err error + ) + + require.NotPanics( + t, func() { + info, err = store.NewDerivedAddress( + t.Context(), params, deriveFn, + ) + }, + ) + + require.Nil(t, info) + require.Error(t, err) + }) +} + // TestNewImportedAddress_NonExistentImportedAccount verifies that calling // NewImportedAddress when the implicit "imported" account doesn't exist // returns db.ErrAccountNotFound. This validates that the implicit account From d676dbfca922058aca87b8bf8b1d2a590bd7c6c0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 17 Apr 2026 11:06:54 +0800 Subject: [PATCH 263/695] waddrmgr: fix address cache bookkeeping --- waddrmgr/manager.go | 46 +++--- waddrmgr/manager_test.go | 77 ++++++++- waddrmgr/scoped_manager.go | 153 +++++++++++++----- waddrmgr/scoped_manager_test.go | 276 ++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 64 deletions(-) diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index 56178c6dc6..e91ca7419e 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -430,24 +430,31 @@ func (m *Manager) IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope, // TODO(yy): Rename this to wipe memory for priv keys. func (m *Manager) lock() { for _, manager := range m.scopedManagers { + manager.mtx.Lock() + // Clear all of the account private keys. - for _, acctInfo := range manager.accountInfo() { + for _, acctInfo := range manager.acctInfo { if acctInfo.acctKeyPriv != nil { acctInfo.acctKeyPriv.Zero() } acctInfo.acctKeyPriv = nil + + // loadAccountInfo warms the last external/internal addresses while + // unlocked, so those managed-address instances may also hold + // cleartext private key bytes outside the LRU cache. + wipeAddressSecret(acctInfo.lastExternalAddr) + wipeAddressSecret(acctInfo.lastInternalAddr) } // Remove clear text private keys and scripts from all address // entries. - for _, ma := range manager.addresses() { - switch addr := ma.(type) { - case *managedAddress: - addr.lock() - case *scriptAddress: - addr.lock() - } - } + manager.addrs.Range(func(_ addrKey, cachedAddr *cachedAddr) bool { + wipeAddressSecret(cachedAddr.addr) + + return true + }) + + manager.mtx.Unlock() } // Remove clear text private master and crypto keys from memory. @@ -608,7 +615,7 @@ func (m *Manager) NewScopedKeyManager(ns walletdb.ReadWriteBucket, scope: scope, addrSchema: addrSchema, rootManager: m, - addrs: make(map[addrKey]ManagedAddress), + addrs: newAddrCache(defaultAddrCacheSize), acctInfo: make(map[uint32]*accountInfo), privKeyCache: lru.NewCache[DerivationPath, *cachedKey]( defaultPrivKeyCacheSize, @@ -1136,17 +1143,17 @@ func (m *Manager) ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error { // Clear and remove all of the encrypted acount private keys. for _, manager := range m.scopedManagers { + manager.mtx.Lock() + for _, acctInfo := range manager.acctInfo { zero.Bytes(acctInfo.acctKeyEncrypted) acctInfo.acctKeyEncrypted = nil } - } - // Clear and remove encrypted private keys and encrypted scripts from - // all address entries. - for _, manager := range m.scopedManagers { - for _, ma := range manager.addrs { - switch addr := ma.(type) { + // Clear and remove encrypted private keys and encrypted scripts from + // all address entries. + manager.addrs.Range(func(_ addrKey, cachedAddr *cachedAddr) bool { + switch addr := cachedAddr.addr.(type) { case *managedAddress: zero.Bytes(addr.privKeyEncrypted) addr.privKeyEncrypted = nil @@ -1154,7 +1161,10 @@ func (m *Manager) ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error { zero.Bytes(addr.scriptEncrypted) addr.scriptEncrypted = nil } - } + + return true + }) + manager.mtx.Unlock() } // Clear and remove encrypted private and script crypto keys. @@ -1671,7 +1681,7 @@ func loadManager(ns walletdb.ReadBucket, pubPassphrase []byte, scopedManagers[scope] = &ScopedKeyManager{ scope: scope, addrSchema: *scopeSchema, - addrs: make(map[addrKey]ManagedAddress), + addrs: newAddrCache(defaultAddrCacheSize), acctInfo: make(map[uint32]*accountInfo), privKeyCache: lru.NewCache[DerivationPath, *cachedKey]( defaultPrivKeyCacheSize, diff --git a/waddrmgr/manager_test.go b/waddrmgr/manager_test.go index bb6de160e3..954139e07a 100644 --- a/waddrmgr/manager_test.go +++ b/waddrmgr/manager_test.go @@ -1407,7 +1407,11 @@ func testChangePassphrase(tc *testContext) bool { func testNewAccount(tc *testContext) bool { if tc.watchingOnly { // Creating new accounts in watching-only mode should return ErrWatchingOnly - err := tc.manager.CanAddAccount() + err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + _, err := tc.manager.NewAccount(ns, "acct-watch-only") + return err + }) if !checkManagerError( tc.t, "Create account in watching-only mode", err, ErrWatchingOnly, @@ -1418,7 +1422,11 @@ func testNewAccount(tc *testContext) bool { return true } // Creating new accounts when wallet is locked should return ErrLocked - err := tc.manager.CanAddAccount() + err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + _, err := tc.manager.NewAccount(ns, "acct-locked") + return err + }) if !checkManagerError( tc.t, "Create account when wallet is locked", err, ErrLocked, ) { @@ -2592,6 +2600,71 @@ func TestScopedKeyManagerManagement(t *testing.T) { } } +// TestManagerLockWipesLastAccountAddresses verifies that locking the root +// manager also zeroes the cleartext private key bytes held by the account-level +// last external and internal managed addresses. +func TestManagerLockWipesLastAccountAddresses(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then warm the default account info + // while unlocked so its cached last addresses hold cleartext private keys. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + var ( + lastExternalAddr *managedAddress + lastInternalAddr *managedAddress + ) + + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + scopedMgr.mtx.Lock() + defer scopedMgr.mtx.Unlock() + + acctInfo, err := scopedMgr.loadAccountInfo(ns, DefaultAccountNum) + if err != nil { + return err + } + + var ok bool + + lastExternalAddr, ok = acctInfo.lastExternalAddr.(*managedAddress) + require.True(t, ok) + + lastInternalAddr, ok = acctInfo.lastInternalAddr.(*managedAddress) + require.True(t, ok) + + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, lastExternalAddr.privKeyCT) + require.NotEmpty(t, lastInternalAddr.privKeyCT) + + // Act: Lock the root manager, which should wipe all cached cleartext key + // material. + mgr.mtx.Lock() + mgr.lock() + mgr.mtx.Unlock() + + // Assert: The account-level last addresses no longer retain cleartext key + // bytes. + require.Nil(t, lastExternalAddr.privKeyCT) + require.Nil(t, lastInternalAddr.privKeyCT) +} + // TestRootHDKeyNeutering tests that callers are unable to create new scoped // managers once the root HD key has been deleted from the database. func TestRootHDKeyNeutering(t *testing.T) { diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 09d7ac2682..058c74c678 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/binary" "fmt" - "maps" "sync" "github.com/btcsuite/btcd/address/v2" @@ -62,6 +61,13 @@ const ( // the wallet. With the default sisize, we'll allocate up to 320 KB to // caching private keys (ignoring pointer overhead, etc). defaultPrivKeyCacheSize = 10_000 + + // defaultAddrCacheSize is the default number of managed addresses that + // each scoped manager keeps hot in memory. We bound this by entry count + // rather than bytes because cached address records vary by type, but at + // the default size the LRU bookkeeping is still only on the order of + // 10 MiB while covering recent lookups for large wallets. + defaultAddrCacheSize = 100_000 ) // DerivationPath represents a derivation path from a particular key manager's @@ -335,9 +341,9 @@ type ScopedKeyManager struct { // derive any new accounts of child keys of accounts. rootManager *Manager - // addrs is a cached map of all the addresses that we currently - // manage. - addrs map[addrKey]ManagedAddress + // addrs caches recently used managed addresses. The cache is bounded so a + // long-lived wallet does not retain an ever-growing in-memory address map. + addrs *lru.Cache[addrKey, *cachedAddr] // acctInfo houses information about accounts including what is needed // to generate deterministic chained keys for each created account. @@ -719,6 +725,49 @@ type cachedKey struct { key btcec.PrivateKey } +// cachedAddr is an address-cache entry in the scoped manager LRU. +type cachedAddr struct { + addr ManagedAddress +} + +// secretWiper is implemented by cached address types that can eagerly zero +// sensitive material from memory. +type secretWiper interface { + lock() +} + +// wipeAddressSecret zeroes any cleartext secret material held by one managed +// address when the concrete type supports eager wiping. +func wipeAddressSecret(addr ManagedAddress) { + if addr == nil { + return + } + + if addr, ok := addr.(secretWiper); ok { + addr.lock() + } +} + +// newAddrCache constructs one bounded address cache with eviction-time +// zeroing for cached secret material. +func newAddrCache(capacity uint64) *lru.Cache[addrKey, *cachedAddr] { + return lru.NewCache[addrKey, *cachedAddr]( + capacity, + lru.WithDeleteCallback(func(_ addrKey, cachedAddr *cachedAddr) { + // The callback may be invoked with a nil entry during + // cache-internal delete paths, so guard before touching cached + // state. + if cachedAddr == nil { + return + } + + // This zeroes cached secret material on eviction. It is not a mutex + // lock, so there is no corresponding unlock step. + wipeAddressSecret(cachedAddr.addr) + }), + ) +} + // Size returns the size of this element. Rather than have the cache limit // based on bytes, we simply report that each element is of size 1, meaning we // can set our cached based on the amount of keys we want to store, rather than @@ -727,6 +776,11 @@ func (c *cachedKey) Size() (uint64, error) { return 1, nil } +// Size returns the size of the cached address entry. +func (c *cachedAddr) Size() (uint64, error) { + return 1, nil +} + // DeriveFromKeyPathCache is identical to DeriveFromKeyPath, however it'll fail // if the account refracted in the DerivationPath isn't already in the // in-memory cache. Callers looking for faster private key retrieval can opt to @@ -991,7 +1045,10 @@ func (s *ScopedKeyManager) loadAndCacheAddress(ns walletdb.ReadBucket, } // Cache and return the new managed address. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -1002,7 +1059,8 @@ func (s *ScopedKeyManager) loadAndCacheAddress(ns walletdb.ReadBucket, // This function MUST be called with the manager lock held for reads. func (s *ScopedKeyManager) existsAddress(ns walletdb.ReadBucket, addressID []byte) bool { // Check the in-memory map first since it's faster than a db access. - if _, ok := s.addrs[addrKey(addressID)]; ok { + _, err := s.addrs.Get(addrKey(addressID)) + if err == nil { return true } @@ -1033,9 +1091,10 @@ func (s *ScopedKeyManager) Address(ns walletdb.ReadBucket, // needed if the lookup fails. s.mtx.RLock() - if ma, ok := s.addrs[addrKey(addr.ScriptAddress())]; ok { + cachedAddr, err := s.addrs.Get(addrKey(addr.ScriptAddress())) + if err == nil { s.mtx.RUnlock() - return ma, nil + return cachedAddr.addr, nil } s.mtx.RUnlock() @@ -1290,8 +1349,7 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, if ma.Address().String() != diskAddr.Address().String() { // The address didn't match up, so we'll manually // delete it from the cache. - delete( - s.addrs, + s.addrs.Delete( addrKey(diskAddr.Address().ScriptAddress()), ) @@ -1321,7 +1379,10 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, for _, info := range addressInfo { ma := info.managedAddr - s.addrs[addrKey(ma.Address().ScriptAddress())] = ma + _, _ = s.addrs.Put( + addrKey(ma.Address().ScriptAddress()), + &cachedAddr{addr: ma}, + ) // Add the new managed address to the list of addresses // that need their private keys derived when the @@ -1531,7 +1592,10 @@ func (s *ScopedKeyManager) extendAddresses(ns walletdb.ReadWriteBucket, // added to the db. for _, info := range addressInfo { ma := info.managedAddr - s.addrs[addrKey(ma.Address().ScriptAddress())] = ma + _, _ = s.addrs.Put( + addrKey(ma.Address().ScriptAddress()), + &cachedAddr{addr: ma}, + ) // Add the new managed address to the list of addresses that // need their private keys derived when the address manager is @@ -1887,7 +1951,16 @@ func (s *ScopedKeyManager) newAccount(ns walletdb.ReadWriteBucket, } // Save last account metadata - return putLastAccount(ns, &s.scope, account) + err = putLastAccount(ns, &s.scope, account) + if err != nil { + return err + } + + // Warm the account cache explicitly so callers do not need a follow-up + // AccountProperties read to make the new account visible in memory. + _, err = s.loadAccountInfo(ns, account) + + return err } // NewAccountWatchingOnly is similar to NewAccount, but for watch-only wallets. @@ -1976,7 +2049,16 @@ func (s *ScopedKeyManager) newAccountWatchingOnly(ns walletdb.ReadWriteBucket, } // Save last account metadata - return putLastAccount(ns, &s.scope, account) + err = putLastAccount(ns, &s.scope, account) + if err != nil { + return err + } + + // Warm the account cache explicitly so callers do not need a follow-up + // AccountProperties read to make the new account visible in memory. + _, err = s.loadAccountInfo(ns, account) + + return err } // RenameAccount renames an account stored in the manager based on the given @@ -2237,7 +2319,6 @@ func (s *ScopedKeyManager) importPublicKey(ns walletdb.ReadWriteBucket, case NestedWitnessPubKey: pubKeyHash := address.Hash160(serializedPubKey) - p2wkhAddr, err := address.NewAddressWitnessPubKeyHash( pubKeyHash, s.rootManager.chainParams, ) @@ -2248,7 +2329,6 @@ func (s *ScopedKeyManager) importPublicKey(ns walletdb.ReadWriteBucket, if err != nil { return err } - addressID = address.Hash160(witnessScript) case TaprootPubKey: @@ -2337,7 +2417,10 @@ func (s *ScopedKeyManager) toImportedPrivateManagedAddress( // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -2360,7 +2443,10 @@ func (s *ScopedKeyManager) toImportedPublicManagedAddress( // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -2564,7 +2650,10 @@ func (s *ScopedKeyManager) importScriptAddress(ns walletdb.ReadWriteBucket, // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(scriptIdent)] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) if updateStartBlock { // Now that the database has been updated, update the start block in @@ -2618,7 +2707,7 @@ func (s *ScopedKeyManager) MarkUsed(ns walletdb.ReadWriteBucket, // Clear caches which might have stale entries for used addresses s.mtx.Lock() - delete(s.addrs, addrKey(addressID)) + s.addrs.Delete(addrKey(addressID)) s.mtx.Unlock() return nil } @@ -2957,27 +3046,3 @@ func (s *ScopedKeyManager) deriveAddr(acctInfo *accountInfo, account, branch, return addr, script, nil } - -// accountInfo returns a copy of the account info map. -func (s *ScopedKeyManager) accountInfo() map[uint32]*accountInfo { - s.mtx.RLock() - defer s.mtx.RUnlock() - - acctInfoCopy := make(map[uint32]*accountInfo, len(s.acctInfo)) - maps.Copy(acctInfoCopy, s.acctInfo) - - return acctInfoCopy -} - -// addresses returns a slice of all managed addresses. -func (s *ScopedKeyManager) addresses() []ManagedAddress { - s.mtx.RLock() - defer s.mtx.RUnlock() - - addrs := make([]ManagedAddress, 0, len(s.addrs)) - for _, ma := range s.addrs { - addrs = append(addrs, ma) - } - - return addrs -} diff --git a/waddrmgr/scoped_manager_test.go b/waddrmgr/scoped_manager_test.go index 323d130e35..023dd87b04 100644 --- a/waddrmgr/scoped_manager_test.go +++ b/waddrmgr/scoped_manager_test.go @@ -1,6 +1,7 @@ package waddrmgr import ( + "bytes" "math" "testing" @@ -11,6 +12,281 @@ import ( "github.com/stretchr/testify/require" ) +// TestNewAccountCachesAccountInfo verifies that creating a new account makes it +// visible in the scoped-manager cache without requiring a follow-up read. +func TestNewAccountCachesAccountInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then fetch the scoped manager. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + var account uint32 + + // Act: Create the new account through the scoped manager. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + account, err = scopedMgr.NewAccount(ns, "acct-1") + + return err + }) + require.NoError(t, err) + require.Contains(t, scopedMgr.ActiveAccounts(), account) + + // Assert: The new account is immediately visible in the cache. + scopedMgr.mtx.RLock() + acctInfo, ok := scopedMgr.acctInfo[account] + scopedMgr.mtx.RUnlock() + require.True(t, ok) + require.Equal(t, "acct-1", acctInfo.acctName) + require.Equal(t, uint32(0), acctInfo.nextExternalIndex) + require.Equal(t, uint32(0), acctInfo.nextInternalIndex) +} + +// TestNewAccountWatchingOnlyCachesAccountInfo verifies that importing a +// watch-only account updates the scoped-manager cache immediately. +func TestNewAccountWatchingOnlyCachesAccountInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create the manager, fetch the scoped manager, and derive the + // account public key to import. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + accountKey := deriveTestAccountKey(t) + require.NotNil(t, accountKey) + + acctKeyPub, err := accountKey.Neuter() + require.NoError(t, err) + + var account uint32 + + // Act: Import the watching-only account. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + account, err = scopedMgr.NewAccountWatchingOnly( + ns, "watch-1", acctKeyPub, 123, nil, + ) + + return err + }) + require.NoError(t, err) + require.Contains(t, scopedMgr.ActiveAccounts(), account) + + // Assert: The imported account metadata is immediately visible in the + // cache. + scopedMgr.mtx.RLock() + acctInfo, ok := scopedMgr.acctInfo[account] + scopedMgr.mtx.RUnlock() + require.True(t, ok) + require.Equal(t, "watch-1", acctInfo.acctName) + require.Equal(t, uint32(123), acctInfo.masterKeyFingerprint) + require.Nil(t, acctInfo.addrSchema) +} + +// TestScopedManagerAddressCacheBounded verifies that the scoped-manager address +// cache stays within its configured capacity while still reloading evicted +// addresses from disk. +func TestScopedManagerAddressCacheBounded(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then replace the scoped address + // cache with a one-entry cache. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + var firstAddr ManagedAddress + + // Act: Derive two addresses so the first is evicted, then load the first + // address again from disk. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + addrs, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 2) + if err != nil { + return err + } + + firstAddr = addrs[0] + + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) + + // Assert: The evicted address can still be reloaded and the cache remains + // bounded. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + addr, err := scopedMgr.Address(ns, firstAddr.Address()) + require.NoError(t, err) + require.Equal(t, firstAddr.Address().String(), addr.Address().String()) + + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) +} + +// TestForEachActiveAddressIgnoresCacheEviction verifies that active-address +// iteration still walks the full DB-backed address set after cache eviction. +func TestForEachActiveAddressIgnoresCacheEviction(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then force the scoped address cache + // down to one entry. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + // Act: Derive two addresses so one is evicted, then iterate the active + // addresses from the DB-backed manager state. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + _, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 2) + + return err + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) + + var seen []string + + // Assert: Iteration still sees both active addresses even though the cache + // only holds one entry. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + return scopedMgr.ForEachActiveAddress( + ns, func(addr address.Address) error { + seen = append(seen, addr.String()) + return nil + }, + ) + }) + require.NoError(t, err) + require.Len(t, seen, 2) +} + +// TestScopedManagerAddressEvictionLocksSecrets verifies that evicting an +// address from the bounded cache zeroes any cleartext private key bytes held by +// that managed address. +func TestScopedManagerAddressEvictionLocksSecrets(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then force the scoped address cache + // down to one entry. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + var firstAddr *managedAddress + + // Act: Derive one address, capture its cleartext key bytes, then derive a + // second address so the first is evicted. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + addrs, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 1) + if err != nil { + return err + } + + var ok bool + + firstAddr, ok = addrs[0].(*managedAddress) + if !ok { + return nil + } + + return nil + }) + require.NoError(t, err) + require.NotNil(t, firstAddr) + require.NotEmpty(t, firstAddr.privKeyCT) + + firstPrivKeyCT := firstAddr.privKeyCT + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + _, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 1) + + return err + }) + require.NoError(t, err) + + // Assert: Eviction zeroes and clears the original managed address's + // cleartext private key bytes. + require.Nil(t, firstAddr.privKeyCT) + require.True(t, bytes.Equal( + firstPrivKeyCT, make([]byte, len(firstPrivKeyCT)), + )) + require.Equal(t, 1, scopedMgr.addrs.Len()) +} + // TestDeriveAddrs verifies that DeriveAddrs correctly derives addresses using // in-memory state, producing the same results as database-backed derivation. func TestDeriveAddrs(t *testing.T) { From e0e5069076f62d0a87cf03d650aec8eaf66fadb9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:26:47 +0800 Subject: [PATCH 264/695] wallet: add wallet-owned address metadata types --- wallet/address_info_test.go | 67 +++++++++++++++++++++++ wallet/address_manager.go | 104 +++++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 wallet/address_info_test.go diff --git a/wallet/address_info_test.go b/wallet/address_info_test.go new file mode 100644 index 0000000000..6303b1e330 --- /dev/null +++ b/wallet/address_info_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// TestAddressInfoFromManagedAddressPubKey verifies conversion of a managed +// pubkey address into wallet-owned metadata. +func TestAddressInfoFromManagedAddressPubKey(t *testing.T) { + t.Parallel() + + // Arrange: Create one managed pubkey address mock with derivation data. + _, mocks := createStartedWalletWithMocks(t) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + addr, err := address.NewAddressWitnessPubKeyHash( + address.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + + mocks.pubKeyAddr.On("Address").Return(addr).Once() + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("Internal").Return(true).Once() + mocks.pubKeyAddr.On("Compressed").Return(true).Once() + mocks.pubKeyAddr.On("PubKey").Return(privKey.PubKey()).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, + waddrmgr.DerivationPath{ + Account: 1, + Branch: 1, + Index: 7, + MasterKeyFingerprint: 99, + }, + true, + ).Once() + + // Act: Convert the managed address into wallet-owned metadata. + info, err := addressInfoFromManagedAddress(mocks.pubKeyAddr) + require.NoError(t, err) + + // Assert: The converted metadata retains the managed address fields and + // derivation information. + require.Equal(t, addr, info.Addr) + require.Equal(t, waddrmgr.WitnessPubKey, info.AddrType) + require.False(t, info.Imported) + require.True(t, info.Internal) + require.True(t, info.Compressed) + require.Equal(t, privKey.PubKey(), info.PubKey) + require.NotNil(t, info.Derivation) + require.Equal(t, waddrmgr.KeyScopeBIP0084, info.Derivation.KeyScope) + require.Equal(t, uint32(1), info.Derivation.Account) + require.Equal(t, uint32(1), info.Derivation.Branch) + require.Equal(t, uint32(7), info.Derivation.Index) + require.Equal(t, uint32(99), info.Derivation.MasterKeyFingerprint) +} diff --git a/wallet/address_manager.go b/wallet/address_manager.go index e3df863355..6d64177979 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -5,8 +5,6 @@ // Package wallet provides the AddressManager interface for generating and // inspecting wallet addresses and scripts. // -// TODO(yy): bring wrapcheck back when implementing the `Store` interface. -// //nolint:wrapcheck package wallet @@ -68,6 +66,72 @@ type AddressProperty struct { Balance btcutil.Amount } +// AddressInfo describes wallet-owned metadata about one managed address. +type AddressInfo struct { + // Addr is the bitcoin address itself. + Addr address.Address + + // AddrType identifies the wallet-managed address type for this concrete + // address. + AddrType waddrmgr.AddressType + + // Imported reports whether the address was imported instead of derived + // from a wallet scope. + Imported bool + + // Internal reports whether the address belongs to the wallet's internal + // branch. + Internal bool + + // Compressed reports whether the underlying pubkey address uses + // compressed keys. + Compressed bool + + // PubKey is set for managed pubkey addresses. + PubKey *btcec.PublicKey + + // Derivation is set when the wallet knows how to derive the address from a + // wallet scope. + Derivation *AddressDerivation +} + +// AddressDerivation captures the wallet derivation metadata for one address. +type AddressDerivation struct { + // KeyScope identifies the scope that owns the address. + KeyScope waddrmgr.KeyScope + + // Account is the BIP-32 account within the scope. + Account uint32 + + // Branch is the BIP-32 branch within the scope. + Branch uint32 + + // Index is the child index within the branch. + Index uint32 + + // MasterKeyFingerprint is the root fingerprint used by + // hardware-wallet-aware flows. + MasterKeyFingerprint uint32 +} + +// OutputScriptInfo captures the address metadata and scripts needed to spend a +// wallet-controlled output. +type OutputScriptInfo struct { + AddressInfo + + // WitnessProgram is the script passed as the witness subscript for witness + // signing. For native P2WPKH and P2TR spends, this is the output pkScript + // itself. For nested P2WPKH-in-P2SH spends, this is the inner witness + // program, for example `OP_0 <20-byte-key-hash>`. + WitnessProgram []byte + + // RedeemScript is the P2SH redeem script that must be pushed into sigScript + // when the output is wrapped in P2SH. This is only set for nested witness + // spends, where it is a single push of the inner witness program. Native + // witness spends, such as P2WPKH and P2TR, leave this nil. + RedeemScript []byte +} + // Script represents the script information required to spend a UTXO. type Script struct { // Addr is the managed address of the UTXO. @@ -137,6 +201,42 @@ type AddressManager interface { // A compile time check to ensure that Wallet implements the interface. var _ AddressManager = (*Wallet)(nil) +// addressInfoFromManagedAddress converts one legacy managed address into the +// wallet-owned metadata shape used by the prep work. +func addressInfoFromManagedAddress( + managedAddr waddrmgr.ManagedAddress) (AddressInfo, error) { + + info := AddressInfo{ + Addr: managedAddr.Address(), + AddrType: managedAddr.AddrType(), + Imported: managedAddr.Imported(), + Internal: managedAddr.Internal(), + Compressed: managedAddr.Compressed(), + } + + pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return info, nil + } + + info.PubKey = pubKeyAddr.PubKey() + + keyScope, derivationPath, ok := pubKeyAddr.DerivationInfo() + if !ok { + return info, nil + } + + info.Derivation = &AddressDerivation{ + KeyScope: keyScope, + Account: derivationPath.Account, + Branch: derivationPath.Branch, + Index: derivationPath.Index, + MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, + } + + return info, nil +} + // NewAddress returns a new address for the given account and address type. // This method is a low-level primitive that will always derive a new, unused // address from the end of the address chain. From 8a799130efcca0df2afb5b1ea58fca1f8fb01481 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:26:56 +0800 Subject: [PATCH 265/695] wallet: rename AddressInfo to GetAddressInfo --- wallet/address_manager.go | 12 ++++++------ wallet/address_manager_benchmark_test.go | 6 +++--- wallet/address_manager_test.go | 14 +++++++------- wallet/psbt_manager.go | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 6d64177979..dde520c9d6 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -169,9 +169,9 @@ type AddressManager interface { addrType waddrmgr.AddressType, change bool) ( address.Address, error) - // AddressInfo returns detailed information about a managed address. If + // GetAddressInfo returns detailed information about a managed address. If // the address is not known to the wallet, an error is returned. - AddressInfo(ctx context.Context, + GetAddressInfo(ctx context.Context, a address.Address) (waddrmgr.ManagedAddress, error) // ListAddresses lists all addresses for a given account, including @@ -523,7 +523,7 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, return unusedAddr, err } -// AddressInfo returns detailed information regarding a wallet address. +// GetAddressInfo returns detailed information regarding a wallet address. // // This method provides metadata about a managed address, such as its type, // derivation path, and whether it's internal or compressed. @@ -547,7 +547,7 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, // - The operation is a direct database lookup, making its complexity roughly // O(1) or O(log N) depending on the database backend's indexing strategy // for addresses. It is a very fast operation. -func (w *Wallet) AddressInfo(_ context.Context, +func (w *Wallet) GetAddressInfo(_ context.Context, a address.Address) (waddrmgr.ManagedAddress, error) { err := w.state.validateStarted() @@ -851,7 +851,7 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // We'll then use the address to look up the managed address from the // database. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return Script{}, fmt.Errorf("unable to get address info "+ "for %s: %w", addr.String(), err) @@ -940,7 +940,7 @@ func (w *Wallet) GetDerivationInfo(ctx context.Context, } // We'll use the address to look up the derivation path. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return nil, err } diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 8032ea38af..e2cf4627ea 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -110,11 +110,11 @@ func BenchmarkListAddressesAPI(b *testing.B) { } } -// BenchmarkAddressInfoAPI benchmarks AddressInfo API and its deprecated +// BenchmarkGetAddressInfoAPI benchmarks GetAddressInfo API and its deprecated // variant using same key scope and identical test data across multiple // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. -func BenchmarkAddressInfoAPI(b *testing.B) { +func BenchmarkGetAddressInfoAPI(b *testing.B) { const ( // startGrowthIteration is the starting iteration index for the // growth sequence. @@ -198,7 +198,7 @@ func BenchmarkAddressInfoAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - _, err := bw.AddressInfo(b.Context(), testAddr) + _, err := bw.GetAddressInfo(b.Context(), testAddr) require.NoError(b, err) } }) diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index ab857342bf..1518087eb2 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -212,7 +212,7 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfo(t.Context(), addr) + addrInfo, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) @@ -410,9 +410,9 @@ func TestGetUnusedAddress(t *testing.T) { require.Equal(t, changeAddr.String(), unusedChangeAddr.String()) } -// TestAddressInfo tests the AddressInfo method to ensure it returns correct +// TestGetAddressInfo tests the GetAddressInfo method to ensure it returns // information for both internal and external addresses. -func TestAddressInfo(t *testing.T) { +func TestGetAddressInfo(t *testing.T) { t.Parallel() // Create a new test wallet. @@ -446,7 +446,7 @@ func TestAddressInfo(t *testing.T) { deps.addr.On("Imported").Return(false).Once() deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() - extInfo, err := w.AddressInfo(t.Context(), extAddr) + extInfo, err := w.GetAddressInfo(t.Context(), extAddr) require.NoError(t, err) // Check the external address info. @@ -482,7 +482,7 @@ func TestAddressInfo(t *testing.T) { deps.addr.On("Imported").Return(false).Once() deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() - intInfo, err := w.AddressInfo(t.Context(), intAddr) + intInfo, err := w.GetAddressInfo(t.Context(), intAddr) require.NoError(t, err) // Check the internal address info. @@ -799,7 +799,7 @@ func TestImportPublicKey(t *testing.T) { deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() - info, err := w.AddressInfo(t.Context(), addr) + info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.NotNil(t, info) } @@ -859,7 +859,7 @@ func TestImportTaprootScript(t *testing.T) { deps.addrStore.On("Address", mock.Anything, addr). Return(deps.taprootAddr, nil).Once() - info, err := w.AddressInfo(t.Context(), addr) + info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.NotNil(t, info) } diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 64a9b94abb..87007252e4 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -431,7 +431,7 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, // We'll then use the address to look up the managed address from the // database. This will give us access to the derivation information. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return fmt.Errorf("unable to get address info for %s: %w", addr.String(), err) @@ -678,7 +678,7 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, } // Then, we'll get the managed address for the change output. - changeAddr, err := w.AddressInfo(ctx, changeScriptInfo.Addr.Address()) + changeAddr, err := w.GetAddressInfo(ctx, changeScriptInfo.Addr.Address()) if err != nil { return err } From 390304e1681f44853de5802b772f198892cac7fd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 20:34:26 +0800 Subject: [PATCH 266/695] wallet: return address metadata from lookup paths --- wallet/address_manager.go | 95 ++++++++++++++++++++++++++++------ wallet/address_manager_test.go | 71 +++++++++++++++++-------- wallet/psbt_manager.go | 38 +++++++------- wallet/psbt_manager_test.go | 12 ++--- 4 files changed, 153 insertions(+), 63 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index dde520c9d6..f0d56a337c 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -171,8 +171,7 @@ type AddressManager interface { // GetAddressInfo returns detailed information about a managed address. If // the address is not known to the wallet, an error is returned. - GetAddressInfo(ctx context.Context, - a address.Address) (waddrmgr.ManagedAddress, error) + GetAddressInfo(ctx context.Context, a address.Address) (AddressInfo, error) // ListAddresses lists all addresses for a given account, including // their balances. @@ -186,7 +185,7 @@ type AddressManager interface { // ImportTaprootScript imports a taproot script for tracking and // spending. ImportTaprootScript(ctx context.Context, - tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) + tapscript waddrmgr.Tapscript) (AddressInfo, error) // ScriptForOutput returns the address, witness program, and redeem // script for a given UTXO. @@ -547,12 +546,12 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, // - The operation is a direct database lookup, making its complexity roughly // O(1) or O(log N) depending on the database backend's indexing strategy // for addresses. It is a very fast operation. -func (w *Wallet) GetAddressInfo(_ context.Context, - a address.Address) (waddrmgr.ManagedAddress, error) { +func (w *Wallet) GetAddressInfo(_ context.Context, a address.Address) ( + AddressInfo, error) { err := w.state.validateStarted() if err != nil { - return nil, err + return AddressInfo{}, err } var managedAddress waddrmgr.ManagedAddress @@ -564,8 +563,11 @@ func (w *Wallet) GetAddressInfo(_ context.Context, return err }) + if err != nil { + return AddressInfo{}, err + } - return managedAddress, err + return addressInfoFromManagedAddress(managedAddress) } // ListAddresses lists all addresses for a given account, including their @@ -766,18 +768,18 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, // - Similar to ImportPublicKey, this operation is dominated by a database // write, making it a fast operation with a complexity of roughly O(1). func (w *Wallet) ImportTaprootScript(_ context.Context, - tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) { + tapscript waddrmgr.Tapscript) (AddressInfo, error) { err := w.state.validateStarted() if err != nil { - return nil, err + return AddressInfo{}, err } manager, err := w.addrStore.FetchScopedKeyManager( waddrmgr.KeyScopeBIP0086, ) if err != nil { - return nil, err + return AddressInfo{}, err } var addr waddrmgr.ManagedAddress @@ -792,15 +794,63 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, return err }) if err != nil { - return nil, err + return AddressInfo{}, err } err = w.cfg.Chain.NotifyReceived([]address.Address{addr.Address()}) if err != nil { - return nil, err + return AddressInfo{}, err } - return addr, nil + return addressInfoFromManagedAddress(addr) +} + +// buildScriptsForAddressInfo constructs the witness and redeem scripts for a +// wallet-owned address metadata record and its corresponding pkScript. +func buildScriptsForAddressInfo(addressInfo AddressInfo, pkScript []byte, + chainParams *chaincfg.Params) ([]byte, []byte, error) { + + if addressInfo.PubKey == nil { + return nil, nil, fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + addressInfo.Addr) + } + + var ( + witnessProgram []byte + redeemScript []byte + ) + + switch { + case addressInfo.AddrType == waddrmgr.NestedWitnessPubKey: + pubKeyHash := address.Hash160( + addressInfo.PubKey.SerializeCompressed(), + ) + + p2wkhAddr, err := address.NewAddressWitnessPubKeyHash( + pubKeyHash, chainParams, + ) + if err != nil { + return nil, nil, err + } + + witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) + if err != nil { + return nil, nil, err + } + + bldr := txscript.NewScriptBuilder() + bldr.AddData(witnessProgram) + + redeemScript, err = bldr.Script() + if err != nil { + return nil, nil, err + } + + default: + witnessProgram = pkScript + } + + return witnessProgram, redeemScript, nil } // ScriptForOutput returns the address, witness program, and redeem script @@ -851,7 +901,14 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // We'll then use the address to look up the managed address from the // database. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var managedAddr waddrmgr.ManagedAddress + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return Script{}, fmt.Errorf("unable to get address info "+ "for %s: %w", addr.String(), err) @@ -940,7 +997,15 @@ func (w *Wallet) GetDerivationInfo(ctx context.Context, } // We'll use the address to look up the derivation path. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var managedAddr waddrmgr.ManagedAddress + + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return nil, err } diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 1518087eb2..1e55645eca 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -196,7 +196,11 @@ func TestNewAddress(t *testing.T) { Return(nil). Once() + deps.addr.On("Address").Return(addr).Once() + deps.addr.On("AddrType").Return(tc.addrType).Once() + deps.addr.On("Imported").Return(false).Once() deps.addr.On("Internal").Return(tc.change).Once() + deps.addr.On("Compressed").Return(true).Once() // Attempt to generate a new address with the specified // parameters. @@ -214,7 +218,7 @@ func TestNewAddress(t *testing.T) { // internal or external. addrInfo, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) - require.Equal(t, tc.change, addrInfo.Internal()) + require.Equal(t, tc.change, addrInfo.Internal) }) } } @@ -450,11 +454,11 @@ func TestGetAddressInfo(t *testing.T) { require.NoError(t, err) // Check the external address info. - require.Equal(t, extAddr.String(), extInfo.Address().String()) - require.False(t, extInfo.Internal()) - require.True(t, extInfo.Compressed()) - require.False(t, extInfo.Imported()) - require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType()) + require.Equal(t, extAddr.String(), extInfo.Addr.String()) + require.False(t, extInfo.Internal) + require.True(t, extInfo.Compressed) + require.False(t, extInfo.Imported) + require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType) // Get a new internal address to test with. addr, _ = address.NewAddressWitnessPubKeyHash( @@ -486,11 +490,11 @@ func TestGetAddressInfo(t *testing.T) { require.NoError(t, err) // Check the internal address info. - require.Equal(t, intAddr.String(), intInfo.Address().String()) - require.True(t, intInfo.Internal()) - require.True(t, intInfo.Compressed()) - require.False(t, intInfo.Imported()) - require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType()) + require.Equal(t, intAddr.String(), intInfo.Addr.String()) + require.True(t, intInfo.Internal) + require.True(t, intInfo.Compressed) + require.False(t, intInfo.Imported) + require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType) } // TestGetDerivationInfoExternalAddressSuccess tests that we can successfully @@ -520,7 +524,11 @@ func TestGetDerivationInfoExternalAddressSuccess(t *testing.T) { // Act: Get the derivation info for the address. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(false).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() privKey, _ := btcec.NewPrivateKey() pubKey := privKey.PubKey() @@ -582,7 +590,11 @@ func TestGetDerivationInfoInternalAddressSuccess(t *testing.T) { // Act: Get the derivation info for the address. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(false).Once() + deps.pubKeyAddr.On("Internal").Return(true).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() privKey, _ := btcec.NewPrivateKey() pubKey := privKey.PubKey() @@ -646,7 +658,7 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { Return(deps.accountManager, nil).Once() deps.accountManager.On("ImportPublicKey", mock.Anything, pubKey, mock.Anything).Return(deps.pubKeyAddr, nil).Once() - deps.pubKeyAddr.On("Address").Return(addr).Maybe() + deps.pubKeyAddr.On("Address").Return(addr).Twice() deps.chain.On("NotifyReceived", []address.Address{addr}). Return(nil).Once() @@ -657,7 +669,14 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { // imported key. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(true).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + deps.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ).Once() _, err = w.GetDerivationInfo(t.Context(), addr) require.ErrorIs(t, err, ErrDerivationPathNotFound) @@ -798,6 +817,15 @@ func TestImportPublicKey(t *testing.T) { // Check that the address is now managed by the wallet. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + deps.pubKeyAddr.On("Imported").Return(true).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + deps.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ).Once() info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) @@ -848,20 +876,19 @@ func TestImportTaprootScript(t *testing.T) { deps.accountManager.On("ImportTaprootScript", mock.Anything, mock.Anything, mock.Anything, uint8(1), false). Return(deps.taprootAddr, nil).Once() - deps.taprootAddr.On("Address").Return(addr).Once() + deps.taprootAddr.On("Address").Return(addr).Twice() + deps.taprootAddr.On("AddrType").Return(waddrmgr.TaprootScript).Once() + deps.taprootAddr.On("Imported").Return(true).Once() + deps.taprootAddr.On("Internal").Return(false).Once() + deps.taprootAddr.On("Compressed").Return(false).Once() deps.chain.On("NotifyReceived", []address.Address{addr}). Return(nil).Once() - _, err = w.ImportTaprootScript(t.Context(), tapscript) + info, err := w.ImportTaprootScript(t.Context(), tapscript) require.NoError(t, err) - - // Check that the address is now managed by the wallet. - deps.addrStore.On("Address", mock.Anything, addr). - Return(deps.taprootAddr, nil).Once() - - info, err := w.GetAddressInfo(t.Context(), addr) - require.NoError(t, err) - require.NotNil(t, info) + require.Equal(t, addr, info.Addr) + require.Equal(t, waddrmgr.TaprootScript, info.AddrType) + require.True(t, info.Imported) } // TestScriptForOutput tests the ScriptForOutput method to ensure it returns the diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 87007252e4..a018518a6c 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -429,24 +430,33 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, ErrUnableToExtractAddress, utxo.PkScript) } - // We'll then use the address to look up the managed address from the - // database. This will give us access to the derivation information. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var ( + managedAddr waddrmgr.ManagedAddress + err error + ) + + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return fmt.Errorf("unable to get address info for %s: %w", addr.String(), err) } - // We'll ensure that the managed address is a public key address, as - // we can only decorate inputs for which we have the private key. + // We'll ensure that the managed address is a public key address, as we can + // only decorate inputs for which we have the private key. pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) if !ok { return fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, managedAddr.Address()) } - // With the managed address, we can now get the derivation information - // for the address. + // With the managed address, we can now get the derivation information for the + // address. derivation, err := derivationForManagedAddress(pubKeyAddr) if err != nil { return err @@ -663,12 +673,6 @@ func (w *Wallet) populatePsbtPacket(ctx context.Context, packet *psbt.Packet, func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, authoredTx *txauthor.AuthoredTx) error { - // TODO(yy): The calls to `w.ScriptForOutput` and `w.AddressInfo` both - // involve database lookups. This could be optimized to a single - // database call to fetch all necessary address information. However, - // for now, this approach favors readability over micro-optimization, - // as this path is not performance-critical. - // // First, we'll get the script information for the change output. changeScriptInfo, err := w.ScriptForOutput( ctx, *authoredTx.Tx.TxOut[authoredTx.ChangeIndex], @@ -677,14 +681,8 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, return err } - // Then, we'll get the managed address for the change output. - changeAddr, err := w.GetAddressInfo(ctx, changeScriptInfo.Addr.Address()) - if err != nil { - return err - } - // We'll ensure that the change address is a public key address. - managedPubKeyAddr, ok := changeAddr.(waddrmgr.ManagedPubKeyAddress) + managedPubKeyAddr, ok := changeScriptInfo.Addr.(waddrmgr.ManagedPubKeyAddress) if !ok { return ErrChangeAddressNotManagedPubKey } diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 4bc19ae3fb..bf23f64139 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -544,7 +544,7 @@ func TestDecorateInputErrImported(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) mocks.pubKeyAddr.On("Imported").Return(true) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() utxo := &wire.TxOut{ Value: 1000, @@ -591,7 +591,7 @@ func TestDecorateInputErrDerivationMissing(t *testing.T) { mocks.pubKeyAddr.On("DerivationInfo").Return( waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, ) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() utxo := &wire.TxOut{ Value: 1000, @@ -1167,7 +1167,7 @@ func TestAddChangeOutputInfoSuccess(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) // Arrange: Mock ManagedPubKeyAddress methods. - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) mocks.pubKeyAddr.On("PubKey").Return(pubKey) // Removed Imported() as addChangeOutputInfo does not call it. @@ -1310,7 +1310,7 @@ func TestAddChangeOutputInfoErrDerivationUnknown(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) // Arrange: Mock ManagedPubKeyAddress methods. - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) // PubKey is not called because DerivationInfo returns false. // DerivationInfo returns false (unknown/imported). @@ -1510,7 +1510,7 @@ func TestPopulatePsbtPacketSuccess(t *testing.T) { ) mocks.pubKeyAddr.On("PubKey").Return(pubKey) mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() // Act: Call populatePsbtPacket. updatedPacket, changeIdx, err := w.populatePsbtPacket( @@ -1649,7 +1649,7 @@ func TestFundPsbtWorkflow(t *testing.T) { mock.MatchedBy(func(addr address.Address) bool { return addr.String() == p2wkhAddr.String() }), - ).Return(mocks.pubKeyAddr, nil).Times(3) + ).Return(mocks.pubKeyAddr, nil).Times(2) // --- Mock accountManager --- // 3. Mock `accountManager.LookupAccount` for the default account: From 78db945c041f4e0dfcc8dc2bf18eaa20aa92887c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:27:44 +0800 Subject: [PATCH 267/695] wallet: separate signer key access from address lookup --- wallet/signer.go | 84 ++++++++++++++++++++++++++++++---- wallet/signer_test.go | 102 +++++++++++++++++++++++++++++++++++------- 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 71509b891c..8ab6d7e33a 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -697,14 +697,7 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, } // The address must be a public key address. - pubKeyAddr, ok := scriptInfo.Addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil, fmt.Errorf("%w: addr %s", - ErrNotPubKeyAddress, scriptInfo.Addr.Address()) - } - - // Get the private key for the derived address. - privKey, err := pubKeyAddr.PrivKey() + privKey, err := w.privKeyForOutput(*params.Output) if err != nil { return nil, fmt.Errorf("cannot get private key: %w", err) } @@ -724,6 +717,81 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, return signAndAssembleScript(params, privKey, &scriptInfo) } +// privKeyForOutput returns the private key needed to sign for the given +// previous output. +func (w *Wallet) privKeyForOutput(output wire.TxOut) ( + *btcec.PrivateKey, error) { + + addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) + if addr == nil { + return nil, fmt.Errorf("%w: from pkscript %x", + ErrUnableToExtractAddress, output.PkScript) + } + + pubKeyAddr, err := w.loadManagedPubKeyAddr(addr) + if err != nil { + return nil, err + } + + return w.resolvePrivKey(pubKeyAddr) +} + +// loadManagedPubKeyAddr loads a managed pubkey address for signer-private key +// access. +func (w *Wallet) loadManagedPubKeyAddr(addr address.Address) ( + waddrmgr.ManagedPubKeyAddress, error) { + + var pubKeyAddr waddrmgr.ManagedPubKeyAddress + + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return err + } + + var ok bool + pubKeyAddr, ok = managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + managedAddr.Address()) + } + + return nil + }) + if err != nil { + return nil, err + } + + return pubKeyAddr, nil +} + +// resolvePrivKey resolves the private key for a managed pubkey address without +// using output-script inspection as the private-key lookup seam. +func (w *Wallet) resolvePrivKey(pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( + *btcec.PrivateKey, error) { + + // Imported spendable keys have no derivation path, so we fall back to the + // dedicated private-key lookup exposed by the managed pubkey address. + if pubKeyAddr.Imported() { + return pubKeyAddr.PrivKey() + } + + keyScope, derivationPath, ok := pubKeyAddr.DerivationInfo() + if !ok { + return nil, fmt.Errorf("%w: addr=%v", ErrDerivationPathNotFound, + pubKeyAddr.Address()) + } + + accountManager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return nil, err + } + + return accountManager.DeriveFromKeyPathCache(derivationPath) +} + // signAndAssembleScript is a helper function that performs the final signing // and script assembly for a given set of parameters and a private key. func signAndAssembleScript(params *UnlockingScriptParams, diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 8ed482daa0..34c2324aa0 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -251,6 +251,22 @@ func deterministicPrivKey(t *testing.T) (*btcec.PrivateKey, *btcec.PublicKey) { return privKey, pubKey } +// expectDerivedSignerPrivKey wires the signer-private key lookup path for a +// derived managed pubkey address. +func expectDerivedSignerPrivKey(t *testing.T, mocks *mockWalletDeps, + scope waddrmgr.KeyScope, path waddrmgr.DerivationPath, + privKey *btcec.PrivateKey) { + + t.Helper() + + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return(scope, path, true).Once() + mocks.addrStore.On("FetchScopedKeyManager", scope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPathCache", path). + Return(privKey, nil).Once() +} + // TestSignDigest tests the signing of a message digest with different signature // types. func TestSignDigest(t *testing.T) { @@ -564,14 +580,21 @@ func TestComputeUnlockingScriptP2PKH(t *testing.T) { // fetch the key from the database. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0044, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -635,14 +658,21 @@ func TestComputeUnlockingScriptP2WKH(t *testing.T) { // when queried, will provide the private key for signing. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -710,7 +740,7 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { // program, so we mock that as well. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return( waddrmgr.NestedWitnessPubKey).Twice() mocks.pubKeyAddr.On("PubKey").Return(pubKey) @@ -719,7 +749,14 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0049Plus, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -785,14 +822,21 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { // when queried, will provide the private key for signing. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.TaprootPubKey).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0086, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. For Taproot, we must use a multi-output @@ -901,12 +945,29 @@ func TestComputeUnlockingScriptFail_PrivKey(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) - // Mock private key retrieval failure. - mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), - errPrivKeyMock).Once() + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0044, + waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, true, + ).Once() + mocks.addrStore.On("FetchScopedKeyManager", waddrmgr.KeyScopeBIP0044). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPathCache", + waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, + ).Return((*btcec.PrivateKey)(nil), errPrivKeyMock).Once() params := &UnlockingScriptParams{ Tx: tx, @@ -947,11 +1008,18 @@ func TestComputeUnlockingScriptFail_Tweak(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0044, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Define failing tweaker. params := &UnlockingScriptParams{ @@ -997,9 +1065,10 @@ func TestComputeUnlockingScriptFail_UnsupportedAddr(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("Imported").Return(true).Once() mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() // Mock unsupported address type. @@ -1042,9 +1111,10 @@ func TestComputeUnlockingScriptUnknownAddrType(t *testing.T) { // Mock address lookup to return a valid managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() // Mock private key retrieval to succeed. + mocks.pubKeyAddr.On("Imported").Return(true).Once() mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() // Mock the address type to return an unknown type (e.g. 99) that falls From 86712f35cb0d7ffc32c3727688eef3f33c5c7030 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 17 Apr 2026 12:12:10 +0800 Subject: [PATCH 268/695] waddrmgr: add AddressType output metadata --- waddrmgr/addr_type.go | 203 +++++++++++++++++++++++++++++++++++++ waddrmgr/addr_type_test.go | 164 ++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 waddrmgr/addr_type.go create mode 100644 waddrmgr/addr_type_test.go diff --git a/waddrmgr/addr_type.go b/waddrmgr/addr_type.go new file mode 100644 index 0000000000..949ddf1074 --- /dev/null +++ b/waddrmgr/addr_type.go @@ -0,0 +1,203 @@ +package waddrmgr + +import ( + "errors" + "fmt" +) + +const ( + // p2pkhPkScriptSize is the fixed scriptPubKey size for P2PKH outputs. + // 25 bytes = OP_DUP (1) + OP_HASH160 (1) + OP_DATA_20 (1) + + // (20) + OP_EQUALVERIFY (1) + OP_CHECKSIG (1). + p2pkhPkScriptSize = 25 + + // p2shPkScriptSize is the fixed scriptPubKey size for P2SH outputs. + // 23 bytes = OP_HASH160 (1) + OP_DATA_20 (1) +