diff --git a/.github/mergify.yml b/.github/mergify.yml
deleted file mode 100644
index c7b457b8..00000000
--- a/.github/mergify.yml
+++ /dev/null
@@ -1,218 +0,0 @@
-pull_request_rules:
-
- # ===============================================================================
- # DEPENDABOT
- # ===============================================================================
-
- - name: Automatic Merge for Dependabot Minor Version Pull Requests
- conditions:
- - -draft
- - author~=^dependabot(|-preview)\[bot\]$
- - check-success='test (1.19.x, ubuntu-latest)'
- - check-success='Analyze (go)'
- actions:
- review:
- type: APPROVE
- message: Automatically approving dependabot pull request
- merge:
- method: merge
-
- # ===============================================================================
- # AUTOMATIC MERGE (APPROVALS)
- # ===============================================================================
-
- - name: Automatic Merge ⬇️ on Approval ✔
- conditions:
- - "#approved-reviews-by>=1"
- - "#review-requested=0"
- - "#changes-requested-reviews-by=0"
- - check-success='test (1.19.x, ubuntu-latest)'
- - check-success='Analyze (go)'
- - -title~=(?i)wip
- - label!=work-in-progress
- - -draft
- actions:
- merge:
- method: merge
-
- # ===============================================================================
- # AUTHOR
- # ===============================================================================
-
- - name: Auto-Assign Author
- conditions:
- - "#assignee=0"
- actions:
- assign:
- users: ["mrz1836"]
-
- # ===============================================================================
- # ALERTS
- # ===============================================================================
-
- - name: Notify on merge
- conditions:
- - merged
- - label=automerge
- actions:
- comment:
- message: "✅ @{{author}}: **{{title}}** has been merged successfully."
- - name: Alert on merge conflict
- conditions:
- - conflict
- - label=automerge
- actions:
- comment:
- message: "🆘 @{{author}}: `{{head}}` has conflicts with `{{base}}` that must be resolved."
- - name: Alert on tests failure for automerge
- conditions:
- - label=automerge
- - status-failure=commit
- actions:
- comment:
- message: "🆘 @{{author}}: unable to merge due to CI failure."
-
- # ===============================================================================
- # LABELS
- # ===============================================================================
- # Automatically add labels when PRs match certain patterns
- #
- # NOTE:
- # - single quotes for regex to avoid accidental escapes
- # - Mergify leverages Python regular expressions to match rules.
- #
- # Semantic commit messages
- # - chore: updating grunt tasks etc.; no production code change
- # - docs: changes to the documentation
- # - feat: feature or story
- # - feature: new feature or story
- # - fix: bug fix for the user, not a fix to a build script
- # - idea: general idea or suggestion
- # - question: question regarding code
- # - test: test related changes
- # - wip: work in progress PR
- # ===============================================================================
-
- - name: Work in Progress
- conditions:
- - "head~=(?i)^wip" # if the PR branch starts with wip/
- actions:
- label:
- add: ["work-in-progress"]
- - name: Hotfix label
- conditions:
- - "head~=(?i)^hotfix" # if the PR branch starts with hotfix/
- actions:
- label:
- add: ["hot-fix"]
- - name: Bug / Fix label
- conditions:
- - "head~=(?i)^(bug)?fix" # if the PR branch starts with (bug)?fix/
- actions:
- label:
- add: ["bug-P3"]
- - name: Documentation label
- conditions:
- - "head~=(?i)^docs" # if the PR branch starts with docs/
- actions:
- label:
- add: ["documentation"]
- - name: Feature label
- conditions:
- - "head~=(?i)^feat(ure)?" # if the PR branch starts with feat(ure)?/
- actions:
- label:
- add: ["feature"]
- - name: Chore label
- conditions:
- - "head~=(?i)^chore" # if the PR branch starts with chore/
- actions:
- label:
- add: ["update"]
- - name: Question label
- conditions:
- - "head~=(?i)^question" # if the PR branch starts with question/
- actions:
- label:
- add: ["question"]
- - name: Test label
- conditions:
- - "head~=(?i)^test" # if the PR branch starts with test/
- actions:
- label:
- add: ["test"]
- - name: Idea label
- conditions:
- - "head~=(?i)^idea" # if the PR branch starts with idea/
- actions:
- label:
- add: ["idea"]
-
- # ===============================================================================
- # CONTRIBUTORS
- # ===============================================================================
-
- - name: Welcome New Contributors
- conditions:
- - and:
- - author!=dependabot[bot]
- - author!=mergify[bot]
- - author!=allcontributors[bot]
- - author!=mrz1836
- - author!=icellan
- - author!=dorzepowski
- - author!=pawellewandowski98
- - author!=arkadiuszos4chain
- - author!=wregulski
- - author!=mwilkosinski
- actions:
- comment:
- message: "Welcome to our open-source project @{{author}}! 💘"
-
- # ===============================================================================
- # STALE BRANCHES
- # ===============================================================================
-
- - name: Close stale pull request
- conditions:
- - base=main
- - -closed
- - updated-at<21 days ago
- actions:
- close:
- message: |
- This pull request looks stale. Feel free to reopen it if you think it's a mistake.
- label:
- add: ["stale"]
-
- # ===============================================================================
- # BRANCHES
- # ===============================================================================
-
- - name: Delete head branch after merge
- conditions:
- - merged
- actions:
- delete_head_branch:
-
- # ===============================================================================
- # CONVENTION
- # ===============================================================================
- # https://www.conventionalcommits.org/en/v1.0.0/
- # Premium feature only
-
- #- name: Conventional Commit
- # conditions:
- # - "title~=^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.+\\))?:"
- # actions:
- # post_check:
- # title: |
- # {% if check_succeed %}
- # Title follows Conventional Commit
- # {% else %}
- # Title does not follow Conventional Commit
- # {% endif %}
- # summary: |
- # {% if not check_succeed %}
- # Your pull request title must follow [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/).
- # {% endif %}
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 0f344d74..ef942111 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -1,62 +1,16 @@
-# See more at: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
-name: run-go-tests
-
-env:
- GO111MODULE: on
-
on:
- pull_request:
- branches:
- - "*"
push:
- branches:
- - "*"
- # schedule:
- # - cron: '1 4 * * *'
+ branches-ignore:
+ - main
+ - master
+
+permissions:
+ contents: write
+ pull-requests: read
jobs:
- yamllint:
- name: Run yaml linter
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- - name: Run yaml linter
- uses: ibiqlik/action-yamllint@v3.1
- asknancy:
- name: Ask Nancy (check dependencies)
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- - name: Write go list
- run: go list -json -m all > go.list
- - name: Ask Nancy
- uses: sonatype-nexus-community/nancy-github-action@v1.0.3
- continue-on-error: true
- test:
- needs: [yamllint, asknancy]
- strategy:
- matrix:
- os: [ubuntu-latest]
- runs-on: ${{ matrix.os }}
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- - name: Install Go from go.mod
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- - name: Cache code
- uses: actions/cache@v4
- with:
- path: |
- ~/go/pkg/mod # Module download cache
- ~/.cache/go-build # Build cache (Linux)
- ~/Library/Caches/go-build # Build cache (Mac)
- '%LocalAppData%\go-build' # Build cache (Windows)
- key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- restore-keys: |
- ${{ runner.os }}-go-
- - name: Run linter and tests
- run: make test-coverage-custom
+ on-push:
+ uses: bactions/workflows/.github/workflows/on-push-go.yml@main
+ secrets:
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
+ SLACK_WEBHOOK_URL: ${{ secrets.ON_PUSH_SLACK_WEBHOOK_URL }}
diff --git a/.golangci-lint.yml b/.golangci-lint.yml
new file mode 100644
index 00000000..722d9c2e
--- /dev/null
+++ b/.golangci-lint.yml
@@ -0,0 +1,201 @@
+# This file contains all available configuration options
+# with their default values.
+
+# options for analysis running
+run:
+ # timeout for analysis, e.g. 30s, 5m, default is 1m
+ timeout: 5m
+
+ # exit code when at least one issue was found, default is 1
+ issues-exit-code: 1
+
+ # include test files or not, default is true
+ tests: true
+
+ # list of build tags, all linters use it. Default is empty list.
+ build-tags:
+ - mytag
+
+
+ # default is true. Enables skipping of directories:
+ # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
+ skip-dirs-use-default: true
+
+
+ # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules":
+ # If invoked with -mod=readonly, the go command is disallowed from the implicit
+ # automatic updating of go.mod described above. Instead, it fails when any changes
+ # to go.mod are needed. This setting is most useful to check that go.mod does
+ # not need updates, such as in a continuous integration and testing system.
+ # If invoked with -mod=vendor, the go command assumes that the vendor
+ # directory holds the correct copies of dependencies and ignores
+ # the dependency descriptions in go.mod.
+ # modules-download-mode: readonly|release|vendor
+
+ # Allow multiple parallel golangci-lint instances running.
+ # If false (default) - golangci-lint acquires file lock on start.
+ allow-parallel-runners: false
+
+
+# output configuration options
+output:
+ # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number"
+ formats: colored-line-number
+
+ # print lines of code with issue, default is true
+ print-issued-lines: true
+
+ # print linter name in the end of issue text, default is true
+ print-linter-name: true
+
+ # make issues output unique by line, default is true
+ uniq-by-line: true
+
+ # add a prefix to the output file references; default is no prefix
+ path-prefix: ""
+
+linters:
+ # Disable all linters.
+ # Default: false
+ disable-all: true
+ # Enable specific linter
+ # https://golangci-lint.run/usage/linters/#enabled-by-default
+ enable:
+ - bodyclose
+ - exhaustive
+ - gosec
+ - prealloc
+ - govet
+ - revive
+ - unconvert
+ - ineffassign
+ - dogsled
+ - exportloopref
+ - sqlclosecheck
+ - nolintlint
+ - errcheck
+ - gosimple
+ - staticcheck
+ - unused
+ - wrapcheck
+ - errorlint
+ - wastedassign
+
+linters-settings:
+ wrapcheck:
+ ignoreSigRegexps:
+ - spverrors\.(Newf|Wrapf)
+ - errutil\.HTTPErrorFormatter
+ ignorePackageGlobs:
+ - "github.com/go-ozzo/ozzo-validation"
+ revive:
+ rules:
+ - name: exported
+ exclude: ["**/testabilities/**", "**/internal/**"]
+ exhaustive:
+ # Presence of "default" case in switch statements satisfies exhaustiveness,
+ # even if all enum members are not listed.
+ # Default: false
+ default-signifies-exhaustive: true
+
+issues:
+ # List of regexps of issue texts to exclude, empty list by default.
+ # But independently of this option we use default exclude patterns,
+ # it can be disabled by `exclude-use-default: false`. To list all
+ # excluded by default patterns execute `golangci-lint run --help`
+ exclude:
+ - Using the variable on range scope .* in function literal
+ - should have a package comment
+
+ # Excluding configuration per-path, per-linter, per-text and per-source
+ exclude-rules:
+ # Exclude some linters from running on tests files.
+ - path: _test\.go
+ linters:
+ - gocyclo
+ - errcheck
+ - gosec
+ - wrapcheck
+ - bodyclose
+
+ # Exclude unsued issues after adding build integration tag.
+ - path: regression_tests
+ linters:
+ - unused
+
+ # Exclude known linters from partially "hard-vendored" code,
+ # which is impossible to exclude via "nolint" comments.
+ - path: internal/hmac/
+ text: "weak cryptographic primitive"
+ linters:
+ - gosec
+
+ # Exclude some "staticcheck" messages
+ - linters:
+ - staticcheck
+ text: "SA1019:"
+
+ # Exclude lll issues for long lines with go:generate
+ - linters:
+ - lll
+ source: "^//go:generate "
+
+ # Independently of option `exclude` we use default exclude patterns,
+ # it can be disabled by this option. To list all
+ # excluded by default patterns execute `golangci-lint run --help`.
+ # Default value for this option is true.
+ exclude-use-default: false
+
+ # which files to skip: they will be analyzed, but issues from them
+ # won't be reported. Default value is empty list, but there is
+ # no need to include all autogenerated files, we confidently recognize
+ # autogenerated files. If it's not please let us know.
+ # "/" will be replaced by current OS file path separator to properly work
+ # on Windows.
+ exclude-files:
+ - ".*\\.my\\.go$"
+ - lib/bad.go
+ # which dirs to skip: issues from them won't be reported;
+ # can use regexp here: generated.*, regexp is applied on full path;
+ # default value is empty list, but default dirs are skipped independently
+ # of this option's value (see skip-dirs-use-default).
+ # "/" will be replaced by current OS file path separator to properly work
+ # on Windows.
+ exclude-dirs:
+ - .github
+ - .make
+ - dist
+
+ # Maximum issues count per one linter. Set to 0 to disable. Default is 50.
+ max-issues-per-linter: 0
+
+ # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
+ max-same-issues: 0
+
+ # Show only new issues created after git revision `REV`
+ new-from-rev: ""
+
+severity:
+ # Default value is empty string.
+ # Set the default severity for issues. If severity rules are defined and the issues
+ # do not match or no severity is provided to the rule this will be the default
+ # severity applied. Severities should match the supported severity names of the
+ # selected out format.
+ # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
+ # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity
+ # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
+ default-severity: error
+
+ # The default value is false.
+ # If set to true severity-rules regular expressions become case-sensitive.
+ case-sensitive: false
+
+ # Default value is empty list.
+ # When a list of severity rules are provided, severity information will be added to lint
+ # issues. Severity rules have the same filtering capability as exclude rules except you
+ # are allowed to specify one matcher per severity rule.
+ # Only affects out formats that support setting severity information.
+ rules:
+ - linters:
+ - dupl
+ severity: info
diff --git a/.golangci-style.yml b/.golangci-style.yml
new file mode 100644
index 00000000..3fa083c1
--- /dev/null
+++ b/.golangci-style.yml
@@ -0,0 +1,137 @@
+# This file contains all available configuration options
+# with their default values.
+
+# options for analysis running
+run:
+ # timeout for analysis, e.g. 30s, 5m, default is 1m
+ timeout: 5m
+
+ # exit code when at least one issue was found, default is 1
+ issues-exit-code: 1
+
+ # include test files or not, default is true
+ tests: true
+
+ # list of build tags, all linters use it. Default is empty list.
+ build-tags:
+ - mytag
+
+ # default is true. Enables skipping of directories:
+ # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
+ skip-dirs-use-default: true
+
+ # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules":
+ # If invoked with -mod=readonly, the go command is disallowed from the implicit
+ # automatic updating of go.mod described above. Instead, it fails when any changes
+ # to go.mod are needed. This setting is most useful to check that go.mod does
+ # not need updates, such as in a continuous integration and testing system.
+ # If invoked with -mod=vendor, the go command assumes that the vendor
+ # directory holds the correct copies of dependencies and ignores
+ # the dependency descriptions in go.mod.
+ # modules-download-mode: readonly|release|vendor
+
+ # Allow multiple parallel golangci-lint instances running.
+ # If false (default) - golangci-lint acquires file lock on start.
+ allow-parallel-runners: false
+
+
+# output configuration options
+output:
+ # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number"
+ formats: colored-line-number
+
+ # print lines of code with issue, default is true
+ print-issued-lines: true
+
+ # print linter name in the end of issue text, default is true
+ print-linter-name: true
+
+ # make issues output unique by line, default is true
+ uniq-by-line: true
+
+ # add a prefix to the output file references; default is no prefix
+ path-prefix: ""
+
+linters:
+ # Disable all linters.
+ # Default: false
+ disable-all: true
+ # Enable specific linter
+ # https://golangci-lint.run/usage/linters/#enabled-by-default
+ enable:
+ - gci
+ - misspell
+
+
+linters-settings:
+ gci:
+ sections:
+ - standard # Standard section: captures all standard packages.
+ - default # Default section: contains all imports that could not be matched to another section type.
+ - prefix(bitcoin-sv/spv-wallet) # Custom section: groups all imports with the specified Prefix.
+ misspell:
+ # Correct spellings using locale preferences for US or UK.
+ # Default is to use a neutral variety of English.
+ # Setting locale to US will correct the British spelling of 'colour' to 'color'.
+ locale: US
+ ignore-words:
+ - bsv
+ - bitcoin
+ - serialise
+
+issues:
+ # Independently of option `exclude` we use default exclude patterns,
+ # it can be disabled by this option.
+ # To list all excluded by default patterns execute `golangci-lint run --help`.
+ # Default: true
+ exclude-use-default: false
+ # Maximum issues count per one linter. Set to 0 to disable. Default is 50.
+ max-issues-per-linter: 0
+ # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
+ max-same-issues: 0
+ # Show only new issues created after git revision `REV`
+ new-from-rev: ""
+ exclude-files:
+ # which files to skip: they will be analyzed, but issues from them
+ # won't be reported. Default value is empty list, but there is
+ # no need to include all autogenerated files, we confidently recognize
+ # autogenerated files. If it's not please let us know.
+ # "/" will be replaced by current OS file path separator to properly work
+ # on Windows.
+ - ".*\\.my\\.go$"
+ - lib/bad.go
+ # which dirs to skip: issues from them won't be reported;
+ # can use regexp here: generated.*, regexp is applied on full path;
+ # default value is empty list, but default dirs are skipped independently
+ # of this option's value (see skip-dirs-use-default).
+ # "/" will be replaced by current OS file path separator to properly work
+ # on Windows.
+ exclude-dirs:
+ - .github
+ - .make
+ - dist
+
+severity:
+ # Default value is empty string.
+ # Set the default severity for issues. If severity rules are defined and the issues
+ # do not match or no severity is provided to the rule this will be the default
+ # severity applied. Severities should match the supported severity names of the
+ # selected out format.
+ # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
+ # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity
+ # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
+ default-severity: error
+
+ # The default value is false.
+ # If set to true severity-rules regular expressions become case-sensitive.
+ case-sensitive: false
+
+ # Default value is empty list.
+ # When a list of severity rules are provided, severity information will be added to lint
+ # issues. Severity rules have the same filtering capability as exclude rules except you
+ # are allowed to specify one matcher per severity rule.
+ # Only affects out formats that support setting severity information.
+ rules:
+ - linters:
+ - dupl
+ severity: info
diff --git a/.golangci.yml b/.golangci.yml
deleted file mode 100644
index 006dcd88..00000000
--- a/.golangci.yml
+++ /dev/null
@@ -1,431 +0,0 @@
-# This file contains all available configuration options
-# with their default values.
-
-# options for analysis running
-run:
- # default concurrency is an available CPU number
- concurrency: 4
-
- # timeout for analysis, e.g. 30s, 5m, default is 1m
- timeout: 5m
-
- # exit code when at least one issue was found, default is 1
- issues-exit-code: 1
-
- # include test files or not, default is true
- tests: true
-
- # list of build tags, all linters use it. Default is empty list.
- build-tags:
- - mytag
-
- # which dirs to skip: issues from them won't be reported;
- # can use regexp here: generated.*, regexp is applied on full path;
- # default value is empty list, but default dirs are skipped independently
- # of this option's value (see skip-dirs-use-default).
- # "/" will be replaced by current OS file path separator to properly work
- # on Windows.
- skip-dirs:
- - .github
- - .make
- - dist
-
- # default is true. Enables skipping of directories:
- # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
- skip-dirs-use-default: true
-
- # which files to skip: they will be analyzed, but issues from them
- # won't be reported. Default value is empty list, but there is
- # no need to include all autogenerated files, we confidently recognize
- # autogenerated files. If it's not please let us know.
- # "/" will be replaced by current OS file path separator to properly work
- # on Windows.
- skip-files:
- - ".*\\.my\\.go$"
- - lib/bad.go
-
- # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules":
- # If invoked with -mod=readonly, the go command is disallowed from the implicit
- # automatic updating of go.mod described above. Instead, it fails when any changes
- # to go.mod are needed. This setting is most useful to check that go.mod does
- # not need updates, such as in a continuous integration and testing system.
- # If invoked with -mod=vendor, the go command assumes that the vendor
- # directory holds the correct copies of dependencies and ignores
- # the dependency descriptions in go.mod.
- #modules-download-mode: readonly|release|vendor
-
- # Allow multiple parallel golangci-lint instances running.
- # If false (default) - golangci-lint acquires file lock on start.
- allow-parallel-runners: false
-
-
-# output configuration options
-output:
- # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number"
- format: colored-line-number
-
- # print lines of code with issue, default is true
- print-issued-lines: true
-
- # print linter name in the end of issue text, default is true
- print-linter-name: true
-
- # make issues output unique by line, default is true
- uniq-by-line: true
-
- # add a prefix to the output file references; default is no prefix
- path-prefix: ""
-
-
-# all available settings of specific linters
-linters-settings:
- dogsled:
- # checks assignments with too many blank identifiers; default is 2
- max-blank-identifiers: 2
- dupl:
- # tokens count to trigger issue, 150 by default
- threshold: 150
- errcheck:
- # report about not checking of errors in type assertions: `a := b.(MyStruct)`;
- # default is false: such cases aren't reported by default.
- check-type-assertions: false
-
- # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
- # default is false: such cases aren't reported by default.
- check-blank: false
-
- # [deprecated] comma-separated list of pairs of the form pkg:regex
- # the regex is used to ignore names within pkg. (default "fmt:.*").
- # see https://github.com/kisielk/errcheck#the-deprecated-method for details
- ignore: fmt:.*,io/ioutil:^Read.*
-
- # path to a file containing a list of functions to exclude from checking
- # see https://github.com/kisielk/errcheck#excluding-functions for details
- #exclude: /path/to/file.txt
- exhaustive:
- # indicates that switch statements are to be considered exhaustive if a
- # 'default' case is present, even if all enum members aren't listed in the
- # switch
- default-signifies-exhaustive: false
- funlen:
- lines: 60
- statements: 40
- gci:
- # put imports beginning with prefix after 3rd-party packages;
- # only support one prefix
- # if not set, use goimports.local-prefixes
- local-prefixes: github.com/org/project
- gocognit:
- # minimal code complexity to report, 30 by default (but we recommend 10-20)
- min-complexity: 10
- nestif:
- # minimal complexity of if statements to report, 5 by default
- min-complexity: 4
- goconst:
- # minimal length of string constant, 3 by default
- min-len: 3
- # minimal occurrences count to trigger, 3 by default
- min-occurrences: 3
- gocritic:
- # Which checks should be enabled; can't be combined with 'disabled-checks';
- # See https://go-critic.github.io/overview#checks-overview
- # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run`
- # By default list of stable checks is used.
- #enabled-checks:
- # - rangeValCopy
-
- # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty
- disabled-checks:
- - regexpMust
-
- # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks.
- # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags".
- enabled-tags:
- - performance
- disabled-tags:
- - experimental
-
- settings: # settings passed to gocritic
- captLocal: # must be valid enabled check name
- paramsOnly: true
- rangeValCopy:
- sizeThreshold: 32
- gocyclo:
- # minimal code complexity to report, 30 by default (but we recommend 10-20)
- min-complexity: 10
- godot:
- # check all top-level comments, not only declarations
- check-all: false
- godox:
- # report any comments starting with keywords, this is useful for TODO or FIXME comments that
- # might be left in the code accidentally and should be resolved before merging
- keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting
- - NOTE
- - OPTIMIZE # marks code that should be optimized before merging
- - HACK # marks hack-arounds that should be removed before merging
- gofmt:
- # simplify code: gofmt with `-s` option, true by default
- simplify: true
- goimports:
- # put imports beginning with prefix after 3rd-party packages;
- # it's a comma-separated list of prefixes
- local-prefixes: github.com/org/project
- gomnd:
- settings:
- mnd:
- # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
- checks:
- - argument
- - case
- - condition
- - operation
- - return
- - assign
- govet:
- # report about shadowed variables
- check-shadowing: true
-
- # settings per analyzer
- settings:
- printf: # analyzer name, run `go tool vet help` to see all analyzers
- funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer
- - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
- - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
- - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
- - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
-
- # enable or disable analyzers by name
- enable:
- - atomicalign
- enable-all: false
- disable-all: false
- depguard:
- list-type: blacklist
- include-go-root: false
- packages:
- - github.com/sirupsen/logrus
- packages-with-error-message:
- # specify an error message to output when a blacklisted package is used
- - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log"
- lll:
- # max line length, lines longer will be reported. Default is 120.
- # '\t' is counted as 1 character by default, and can be changed with the tab-width option
- line-length: 120
- # tab width in spaces. Default to 1.
- tab-width: 1
- maligned:
- # print struct with more effective memory layout or not, false by default
- suggest-new: true
- misspell:
- # Correct spellings using locale preferences for US or UK.
- # Default is to use a neutral variety of English.
- # Setting locale to US will correct the British spelling of 'colour' to 'color'.
- locale: US
- ignore-words:
- - bsv
- - bitcoin
- nakedret:
- # make an issue if func has more lines of code than this setting, and it has naked returns; default is 30
- max-func-lines: 30
- prealloc:
- # XXX: we don't recommend using this linter before doing performance profiling.
- # For most programs usage of prealloc will be a premature optimization.
-
- # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
- # True by default.
- simple: true
- range-loops: true # Report preallocation suggestions on range loops, true by default
- for-loops: false # Report preallocation suggestions on for loops, false by default
- nolintlint:
- # Enable to ensure that nolint directives are all used. Default is true.
- allow-unused: false
- # Disable to ensure that nolint directives don't have a leading space. Default is true.
- allow-leading-space: true
- # Exclude following linters from requiring an explanation. Default is [].
- allow-no-explanation: []
- # Enable to require an explanation of nonzero length after each nolint directive. Default is false.
- require-explanation: true
- # Enable to require nolint directives to mention the specific linter being suppressed. Default is false.
- require-specific: true
- rowserrcheck:
- packages:
- - github.com/jmoiron/sqlx
- testpackage:
- # regexp pattern to skip files
- skip-regexp: (export|internal)_test\.go
- unparam:
- # Inspect exported functions, default is false. Set to true if no external program/library imports your code.
- # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
- # if it's called for sub-dir of a project it can't find external interfaces. All text editor integrations
- # with golangci-lint call it on a directory with the changed file.
- check-exported: false
- unused:
- # treat code as a program (not a library) and report unused exported identifiers; default is false.
- # XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
- # if it's called for sub-dir of a project it can't find function usages. All text editor integrations
- # with golangci-lint call it on a directory with the changed file.
- check-exported: false
- whitespace:
- multi-if: false # Enforces newlines (or comments) after every multi-line if statement
- multi-func: false # Enforces newlines (or comments) after every multi-line function signature
- wsl:
- # If true append is only allowed to be cuddled if appending value is
- # matching variables, fields or types online above. Default is true.
- strict-append: true
- # Allow calls and assignments to be cuddled as long as the lines have any
- # matching variables, fields or types. Default is true.
- allow-assign-and-call: true
- # Allow multiline assignments to be cuddled. Default is true.
- allow-multiline-assign: true
- # Allow declarations (var) to be cuddled.
- allow-cuddle-declarations: true
- # Allow trailing comments in ending of blocks
- allow-trailing-comment: false
- # Force newlines in end of case at this limit (0 = never).
- force-case-trailing-whitespace: 0
- # Force cuddling of err checks with err var assignment
- force-err-cuddling: false
- # Allow leading comments to be separated with empty liens
- allow-separated-leading-comment: false
- gofumpt:
- # Choose whether to use the extra rules that are disabled
- # by default
- extra-rules: false
-
- # The custom section can be used to define linter plugins to be loaded at runtime. See README doc
- # for more info.
- custom:
- # Each custom linter should have a unique name.
- #example:
- # The path to the plugin *.so. Can be absolute or local. Required for each custom linter
- #path: /path/to/example.so
- # The description of the linter. Optional, just for documentation purposes.
- #description: This is an example usage of a plugin linter.
- # Intended to point to the repo location of the linter. Optional, just for documentation purposes.
- #original-url: github.com/golangci/example-linter
-
-linters:
- enable:
- - megacheck
- - govet
- - gosec
- - bodyclose
- - revive
- - unconvert
- - dupl
- - misspell
- - ineffassign
- - dogsled
- - prealloc
- - exportloopref
- - exhaustive
- - sqlclosecheck
- - nolintlint
- - gci
- - goconst
- disable:
- - gocritic # use this for very opinionated linting
- - gochecknoglobals
- - whitespace
- - wsl
- - goerr113
- - godot
- - testpackage
- - nestif
- - nlreturn
- disable-all: false
- presets:
- - bugs
- - unused
- fast: false
-
-
-issues:
- # List of regexps of issue texts to exclude, empty list by default.
- # But independently of this option we use default exclude patterns,
- # it can be disabled by `exclude-use-default: false`. To list all
- # excluded by default patterns execute `golangci-lint run --help`
- exclude:
- - Using the variable on range scope .* in function literal
-
- # Excluding configuration per-path, per-linter, per-text and per-source
- exclude-rules:
- # Exclude some linters from running on tests files.
- - path: _test\.go
- linters:
- - gocyclo
- - errcheck
- - dupl
- - gosec
-
- # Exclude known linters from partially "hard-vendored" code,
- # which is impossible to exclude via "nolint" comments.
- - path: internal/hmac/
- text: "weak cryptographic primitive"
- linters:
- - gosec
-
- # Exclude some "staticcheck" messages
- - linters:
- - staticcheck
- text: "SA1019:"
-
- # Exclude lll issues for long lines with go:generate
- - linters:
- - lll
- source: "^//go:generate "
-
- # Independently of option `exclude` we use default exclude patterns,
- # it can be disabled by this option. To list all
- # excluded by default patterns execute `golangci-lint run --help`.
- # Default value for this option is true.
- exclude-use-default: false
-
- # The default value is false. If set to true exclude and exclude-rules
- # regular expressions become case-sensitive.
- exclude-case-sensitive: false
-
- # Maximum issues count per one linter. Set to 0 to disable. Default is 50.
- max-issues-per-linter: 0
-
- # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
- max-same-issues: 0
-
- # Show only new issues: if there are "un-staged" changes or untracked files,
- # only those changes are analyzed, else only changes in HEAD~ are analyzed.
- # It's a super-useful option for integration of golangci-lint into existing
- # large codebase. It's not practical to fix all existing issues at the moment
- # of integration: much better don't allow issues in new code.
- # Default is false.
- new: false
-
- # Show only new issues created after git revision `REV`
- new-from-rev: ""
-
- # Show only new issues created in git patch with set file path.
- #new-from-patch: path/to/patch/file
-
-severity:
- # Default value is empty string.
- # Set the default severity for issues. If severity rules are defined and the issues
- # do not match or no severity is provided to the rule this will be the default
- # severity applied. Severities should match the supported severity names of the
- # selected out format.
- # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
- # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity
- # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
- default-severity: error
-
- # The default value is false.
- # If set to true severity-rules regular expressions become case-sensitive.
- case-sensitive: false
-
- # Default value is empty list.
- # When a list of severity rules are provided, severity information will be added to lint
- # issues. Severity rules have the same filtering capability as exclude rules except you
- # are allowed to specify one matcher per severity rule.
- # Only affects out formats that support setting severity information.
- rules:
- - linters:
- - dupl
- severity: info
diff --git a/README.md b/README.md
index bfc00403..e4db7e47 100644
--- a/README.md
+++ b/README.md
@@ -19,229 +19,242 @@
## Table of Contents
-- [SPV Wallet: Go Client](#spv-wallet-go-client)
- - [Table of Contents](#table-of-contents)
- - [Installation](#installation)
- - [Documentation](#documentation)
- - [Built-in Features](#built-in-features)
- - [Automatic Releases on Tag Creation (recommended)](#automatic-releases-on-tag-creation-recommended)
- - [Manual Releases (optional)](#manual-releases-optional)
- - [Usage](#usage)
- - [Examples \& Tests](#examples--tests)
- - [Benchmarks](#benchmarks)
- - [Code Standards](#code-standards)
- - [Usage](#usage-1)
- - [Contributing](#contributing)
- - [License](#license)
+1. [Requirements and Compatibility](#requirements-and-compatibility)
+1. [Quick start](#quick-start)
+1. [Documentation](#documentation)
+1. [Testing and Development Standards](#testing-and-development-standards)
+1. [Examples](/examples/README.md)
+1. [License](#license)
-
-
-## Installation
+## Requirements and Compatibility
-**spv-wallet-go-client** requires a [supported release of Go](https://golang.org/doc/devel/release.html#policy).
+Instalation:
```shell script
go get -u github.com/bitcoin-sv/spv-wallet-go-client
```
-
+## Requirements
+
+- **Go Version**: The `spv-wallet-go-client` requires **Go version 1.22.5** or a later supported release of Go. Ensure your Go environment meets this requirement before using the client.
+
+
+## Compatibility and Support
+
+### Deprecation Notice
+The client **does not support** the following:
+- **Admin and non-admin old endpoints** of the SPV Wallet API based on the `/v1/` prefix.
+- Deprecated methods for building query parameters for HTTP requests.
+
+### Current Compatibility
+The client is designed for full compatibility with the newer `/api/v1/` endpoints exposed by the SPV Wallet API. It focuses on aligning with the latest standards and structure provided by the API.
+
+### API Admin Endpoints Compatibility
+
+#### Access Keys API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|----------------- |
+| GET | /api/v1/admin/users/keys | Search access keys | ✅ | [API](/internal/api/v1/admin/accesskeys/access_keys_api.go#L25) | ✅ |
+
+#### Contacts API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|------------------------------------------------|-------------- |
+| GET | /api/v1/admin/contacts | Search contacts | ✅ | [API](/internal/api/v1/admin/contacts/contacts_api.go#L42) | ✅ |
+| POST | /api/v1/admin/contacts/confirmations | Confirm contact | ✅ | [API](/internal/api/v1/admin/contacts/contacts_api.go#L83) | ❌ |
+| PUT | /api/v1/admin/contacts/{id} | Update contact | ✅ | [API](/internal/api/v1/admin/contacts/contacts_api.go#L68) | ❌ |
+| DELETE | /api/v1/admin/contacts/{id} | Delete contact | ✅ | [API](/internal/api/v1/admin/contacts/contacts_api.go#L95) | ❌ |
+| POST | /api/v1/admin/contacts/{paymail} | Create contact | ✅ | [API](/internal/api/v1/admin/contacts/contacts_api.go#L27) | ❌ |
+
+#### Invitations API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|--------------------------------------------------|-------------------|
+| POST | /api/v1/admin/invitations/{id} | Accept invitation | ✅ | [API](/internal/api/v1/admin/invitations/invitations_api.go#L22) | ❌ |
+| DELETE | /api/v1/admin/invitations/{id} | Reject invitation | ✅ | [API](/internal/api/v1/admin/invitations/invitations_api.go#L35) | ❌ |
+
+
+#### Paymails API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|--------------------------------------------------|------------------|
+| GET | /api/v1/admin/paymails | Search paymails | ✅ | [API](/internal/api/v1/admin/paymails/paymails_api.go#L73) | ✅ |
+| POST | /api/v1/admin/paymails | Create paymail | ✅ | [API](/internal/api/v1/admin/paymails/paymails_api.go#L44) | ❌ |
+| GET | /api/v1/admin/paymails/{id} | Retrieve paymail | ✅ | [API](/internal/api/v1/admin/paymails/paymails_api.go#L59) | ❌ |
+| DELETE | /api/v1/admin/paymails/{id} | Delete paymail | ✅ | [API](/internal/api/v1/admin/paymails/paymails_api.go#L27) | ❌ |
+
+#### Stats API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|-----------------------------------------------------|---------------|
+| GET | /api/v1/admin/stats | Retrieve stats | ✅ | [API](/internal/api/v1/admin/stats/stats_api.go#L23) | ✅ |
+
+#### Status API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|-------------------------------------------------------|-----------------|
+| GET | /api/v1/admin/status | Retrieve status | ✅ | [API](/internal/api/v1/admin/status/status_api.go#L23) | ❌ |
+
+#### Transactions API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|--------------------------------------------------|-----------------------|
+| GET | /api/v1/admin/transactions | Search transactions | ✅ | [API](/internal/api/v1/admin/transactions/transactions_api.go#L39) | ✅ |
+| GET | /api/v1/admin/transactions/{id} | Retrieve transaction | ✅ | [API](/internal/api/v1/admin/transactions/transactions_api.go#L26)| ❌ |
+
+#### UTXOs API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|-----------------------------------------------------| -----------------|
+| GET | /api/v1/admin/utxos | Search UTXOs | ✅ | [API](/internal/api/v1/admin/utxos/utxos_api.go#L25) | ✅ |
+
+#### Webhooks API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|---------------------------------------------------|---------------|
+| GET | /api/v1/admin/webhooks/subscriptions | Subscribe to webhook | ✅ | [API](/internal/api/v1/admin/webhooks/webhooks_api.go#L23) | ❌ |
+| DELETE | /api/v1/admin/webhooks/subscriptions | Unsubscribe webhook | ✅ | [API](/internal/api/v1/admin/webhooks/webhooks_api.go#L36) | ❌ |
+
+#### XPubs API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|---------------------------------------|----------------------|----------------|-----------------------------------------------------|-------------|
+| GET | /api/v1/admin/users | Search XPubs | ✅ | [API](/internal/api/v1/admin/xpubs/xpubs_api.go#L41) | ✅ |
+| POST | /api/v1/admin/users | Create XPub | ✅ | [API](/internal/api/v1/admin/xpubs/xpubs_api.go#L27) | ❌ |
+
+### API Non-Admin Endpoints Compatibility
+
+#### Access Keys API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|------------------|
+| GET | /api/v1/users/current/keys | Search access keys | ✅ | [API](/internal/api/v1/user/accesskeys/access_key_api.go#L56) | ✅ |
+| POST | /api/v1/users/current/keys | Create access key | ✅ | [API](/internal/api/v1/user/accesskeys/access_key_api.go#L27) | ❌ |
+| GET | /api/v1/users/current/keys/{id} | Retrieve access key | ✅ | [API](/internal/api/v1/user/accesskeys/access_key_api.go#L42) | ❌ |
+| DELETE | /api/v1/users/current/keys/{id} | Revoke access key | ✅ | [API](/internal/api/v1/user/accesskeys/access_key_api.go#L82) | ❌ |
+
+#### Contacts API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|--------------|
+| GET | /api/v1/contacts | Search contacts | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L27) | ✅ |
+| GET | /api/v1/contacts/{paymail} | Retrieve contact | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L53) | ❌ |
+| PUT | /api/v1/contacts/{paymail} | Upsert contact | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L67) | ❌ |
+| DELETE | /api/v1/contacts/{paymail} | Remove contact | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L89) | ❌ |
+| POST | /api/v1/contacts/{paymail} | Confirm contact | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L101)| ❌ |
+| DELETE | /api/v1/contacts/{paymail} | Unconfirm contact | ✅ | [API](/internal/api/v1/user/contacts/contacts_api.go#L113)| ❌ |
+
+#### Invitations API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|---------------------------|
+| POST | /api/v1/invitations/{paymail}/contacts | Accept invitation | ✅ | [API](/internal/api/v1/user/invitations/invitations_api.go#L22) | ❌ |
+| DELETE | /api/v1/invitations/{paymail} | Reject invitation | ✅ | [API](/internal/api/v1/user/invitations/invitations_api.go#L34) | ❌ |
+
+#### Merkle Roots API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|-------------------|
+| GET | /api/v1/merkleroots | Search Merkle roots | ✅ | [API](/internal/api/v1/user/merkleroots/merkleroots_api.go#L36)| ❌ |
+
+#### Paymails API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|------------------|
+| GET | /api/v1/paymails | Search paymails | ✅ | [API](/internal/api/v1/user/paymails/paymails_api.go#L25) | ✅ |
+
+#### Transactions API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|--------------------------------------------------|----------------------|
+| GET | /api/v1/transactions | Search transactions | ✅ | [API](/internal/api/v1/user/transactions/transactions_api.go#L137) |✅ |
+| POST | /api/v1/transactions | Record transaction | ✅ | [API](/internal/api/v1/user/transactions/transactions_api.go#L93) |❌ |
+| POST | /api/v1/transactions/drafts | Draft transaction | ✅ | [API](/internal/api/v1/user/transactions/transactions_api.go#L78) |❌ |
+| GET | /api/v1/transactions/{id} | Retrieve transaction | ✅ | [API](/internal/api/v1/user/transactions/transactions_api.go#L123) |❌ |
+| PATCH | /api/v1/transactions/{id} | Update transaction | ✅ | [API](/internal/api/v1/user/transactions/transactions_api.go#L108) |❌ |
+
+#### UTXOs API
+| HTTP Method | Endpoint | Action | Support Status | API Code | Pagination |
+|-------------|-------------------------------|----------------------|----------------|----------------------------------------------------|---------------|
+| GET | /api/v1/utxos | Search UTXOs | ✅ | [API](/internal/api/v1/user/utxos/utxos_api.go#L25) | ❌ |
+
+#### XPubs API
+| HTTP Method | Endpoint | Action | Support Status | API Code |Pagination |
+|-------------|-------------------------------|------------------------------|----------------|---------------------------------------------------|-----------|
+| GET | /api/v1/users/current | Retrieve current user info | ✅ | [API](/internal/api/v1/user/xpubs/xpub_api.go#L24) | ❌ |
+| PATCH | /api/v1/users/current | Update current user info | ✅ | [API](/internal/api/v1/user/xpubs/xpub_api.go#L24) | ❌ |
+
+
+
+## Feature Updates
+
+While the client strives to support the latest API features, there may be a delay in fully integrating new functionalities. If you encounter any issues or have questions:
+- Refer to the official documentation.
+- Reach out for support to ensure a smooth development experience.
+
+
+
+## Quick start
+
+The implementation enforces separation of concerns by isolating admin and non-admin APIs, requiring separate initialization for their respective clients. This ensures clarity and modularity when utilizing the exposed functionality.
+
+### `UserAPI` Initialization Methods:
+
+### 1. [`NewUserAPIWithAccessKey`](/user_api.go#L468)
+- **Description:** Initializes a `UserAPI` instance using an access key for authentication.
+- **Note:** Requests made with this instance will be securely signed, ensuring integrity and authenticity.
+
+### 2. [`NewUserAPIWithXPriv`](/user_api.go#L449)
+- **Description:** Initializes a `UserAPI` instance using an extended private key (xPriv) for authentication.
+- **Note:** Requests made with this instance will also be securely signed.
+- **Recommendation:** This option offers a high level of security, making it a preferred choice alongside the access key option.
+
+### 3. [`NewUserAPIWithXPub`](/user_api.go#L435)
+- **Description:** Initializes a `UserAPI` instance using an extended public key (xPub).
+- **Note:** Requests made with this instance will not be signed.
+- **Security Advisory:** For enhanced security, it is strongly recommended to use either `NewUserAPIWithAccessKey` or `NewUserAPIWithXPriv` instead, as unsigned requests may be less secure.
+
+
+### `AdminAPI` Initialization Methods:
+
+### 1. [`NewAdminAPIWithXPriv`](/admin_api.go#L375)
+- **Description:** Initializes a `AdminAPI` instance using an extended private key (xPriv) for authentication.
+- **Note:** Requests made with this instance will be securely signed, ensuring integrity and authenticity.
+
+### 2. [`NewAdminAPIWithXPub`](/admin_api.go#L390)
+- **Description:** Initializes a `AdminAPI` instance using an extended public key (xPub).
+- **Note:** Requests made with this instance will not be signed.
+- **Security Advisory:** For enhanced security, it is strongly recommended to use either `NewAdminAPIWithXPriv`instead, as unsigned requests may be less secure.
+
+**Code snippets:**
+- [AdminAPI example](/examples/admin_add_user/admin_add_user.go)
+- [UserAPI example](/examples/list_transactions/list_transactions.go)
+
## Documentation
+
View the generated [documentation](https://pkg.go.dev/github.com/bitcoin-sv/spv-wallet-go-client)
For in-depth information and guidance, please refer to the [SPV Wallet Documentation](https://docs.bsvblockchain.org/network-topology/applications/spv-wallet).
[![GoDoc](https://godoc.org/github.com/bitcoin-sv/spv-wallet-go-client?status.svg&style=flat&v=2)](https://pkg.go.dev/github.com/bitcoin-sv/spv-wallet-go-client)
-
-
-
-Repository Features
-
-
-This repository was created using [MrZ's `go-template`](https://github.com/mrz1836/go-template#about)
-
-#### Built-in Features
-- Continuous integration via [GitHub Actions](https://github.com/features/actions)
-- Build automation via [Make](https://www.gnu.org/software/make)
-- Dependency management using [Go Modules](https://github.com/golang/go/wiki/Modules)
-- Code formatting using [gofumpt](https://github.com/mvdan/gofumpt) and linting with [golangci-lint](https://github.com/golangci/golangci-lint) and [yamllint](https://yamllint.readthedocs.io/en/stable/index.html)
-- Unit testing with [testify](https://github.com/stretchr/testify), [race detector](https://blog.golang.org/race-detector), code coverage [HTML report](https://blog.golang.org/cover) and [Codecov report](https://codecov.io/)
-- Releasing using [GoReleaser](https://github.com/goreleaser/goreleaser) on [new Tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging)
-- Dependency scanning and updating thanks to [Dependabot](https://dependabot.com) and [Nancy](https://github.com/sonatype-nexus-community/nancy)
-- Security code analysis using [CodeQL Action](https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/about-code-scanning)
-- Automatic syndication to [pkg.go.dev](https://pkg.go.dev/) on every release
-- Generic templates for [Issues and Pull Requests](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository) in GitHub
-- All standard GitHub files such as `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`
-- Code [ownership configuration](.github/CODEOWNERS) for GitHub
-- All your ignore files for [vs-code](.editorconfig), [docker](.dockerignore) and [git](.gitignore)
-- Automatic sync for [labels](.github/labels.yml) into GitHub using a pre-defined [configuration](.github/labels.yml)
-- Built-in powerful merging rules using [Mergify](https://mergify.io/)
-- Welcome [new contributors](.github/mergify.yml) on their first Pull-Request
-- Follows the [standard-readme](https://github.com/RichardLitt/standard-readme/blob/master/spec.md) specification
-- [Visual Studio Code](https://code.visualstudio.com) configuration with [Go](https://code.visualstudio.com/docs/languages/go)
-- (Optional) [Slack](https://slack.com), [Discord](https://discord.com) or [Twitter](https://twitter.com) announcements on new GitHub Releases
-- (Optional) Easily add [contributors](https://allcontributors.org/docs/en/bot/installation) in any Issue or Pull-Request
-
-
-
-
-Package Dependencies
-
-
-- [stretchr/testify](https://github.com/stretchr/testify)
-
-
-
-Library Deployment
-
-
-Releases are automatically created when you create a new [git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging)!
-
-If you want to manually make releases, please install GoReleaser:
+# Testing and Development Standards
-[goreleaser](https://github.com/goreleaser/goreleaser) for easy binary or library deployment to GitHub and can be installed:
-- **using make:** `make install-releaser`
-- **using brew:** `brew install goreleaser`
+The current implementation includes comprehensive support for:
+- **Unit Tests:** To validate individual components and ensure they work as expected in isolation.
+- **Regression Tests:** To verify compatibility with the latest released version of the SPV Wallet API and to prevent unintended functionality breaks.
-The [.goreleaser.yml](.goreleaser.yml) file is used to configure [goreleaser](https://github.com/goreleaser/goreleaser).
+These tests ensure a stable and reliable integration with the SPV Wallet API, maintaining high-quality code and robust functionality.
-
-
-### Automatic Releases on Tag Creation (recommended)
-Automatic releases via [GitHub Actions](.github/workflows/release.yml) from creating a new tag:
-```shell
-make tag version=1.2.3
-```
-
-
-
-### Manual Releases (optional)
-Use `make release-snap` to create a snapshot version of the release, and finally `make release` to ship to production (manually).
-
-
-
-
-
-
-Makefile Commands
-
-
-View all `makefile` commands
-```shell script
-make help
-```
-
-List of all current commands:
-```text
-all Runs multiple commands
-clean Remove previous builds and any cached data
-clean-mods Remove all the Go mod cache
-coverage Shows the test coverage
-diff Show the git diff
-generate Runs the go generate command in the base of the repo
-godocs Sync the latest tag with GoDocs
-help Show this help message
-install Install the application
-install-all-contributors Installs all contributors locally
-install-go Install the application (Using Native Go)
-install-releaser Install the GoReleaser application
-lint Run the golangci-lint application (install if not found)
-release Full production release (creates release in GitHub)
-release Runs common.release then runs godocs
-release-snap Test the full release (build binaries)
-release-test Full production test release (everything except deploy)
-replace-version Replaces the version in HTML/JS (pre-deploy)
-tag Generate a new tag and push (tag version=0.0.0)
-tag-remove Remove a tag if found (tag-remove version=0.0.0)
-tag-update Update an existing tag to current commit (tag-update version=0.0.0)
-test Runs lint and ALL tests
-test-ci Runs all tests via CI (exports coverage)
-test-ci-no-race Runs all tests via CI (no race) (exports coverage)
-test-ci-short Runs unit tests via CI (exports coverage)
-test-no-lint Runs just tests
-test-short Runs vet, lint and tests (excludes integration tests)
-test-unit Runs tests and outputs coverage
-uninstall Uninstall the application (and remove files)
-update-contributors Regenerates the contributors html/list
-update-linter Update the golangci-lint package (macOS only)
-vet Run the Go vet application
-```
-
-
-
-
-## Usage
-Checkout all the [examples](examples)!
-
-
-
-### Examples & Tests
-All unit tests and [examples](examples) run via [GitHub Actions](https://github.com/bitcoin-sv/spv-wallet-go-client/actions) and
-uses [Go version 1.19.x](https://golang.org/doc/go1.19). View the [configuration file](.github/workflows/run-tests.yml).
-
-
+## Commands
Run all tests (including integration tests)
```shell script
make test
```
-
Run tests (excluding integration tests)
```shell script
make test-short
```
-
-
-### Benchmarks
-Run the Go benchmarks:
-```shell script
-make bench
-```
-
-
+## Development Guidelines
-## Code Standards
-Read more about this Go project's [code standards](.github/CODE_STANDARDS.md).
+Each new proposed functionality must adhere to the following principles:
+1. **Code of Conduct:** Contributions should align with the repository's code of conduct, fostering a positive and collaborative environment.
+1. **Repository Standards:** Proposals and implementations should strictly follow the coding standards, conventions, and best practices outlined in the repository documentation.
-
+By adhering to these guidelines, contributors can ensure that their changes are consistent, maintainable, and compatible with the SPV Wallet API.
-## Usage
-
-
-```
-// http example
-func main() {
-
- // Generate keys
- keys, _ := xpriv.Generate()
-
- // Create a client
- client, _ := walletclient.New(
- walletclient.WithXPriv(keys.XPriv()),
- walletclient.WithHTTP("localhost:3001"),
- walletclient.WithSignRequest(true))
-
- fmt.Println(client.IsSignRequest())
-}
-
-```
-
-Checkout all the [examples](examples)!
-
-
-
-## Contributing
-All kinds of contributions are welcome!
-
-To get started, take a look at [code standards](.github/CODE_STANDARDS.md).
-
+All kinds of contributions are welcome 🎉! To get started, take a look at [code standards](.github/CODE_STANDARDS.md).
View the [contributing guidelines](.github/CODE_STANDARDS.md#3-contributing) and follow the [code of conduct](.github/CODE_OF_CONDUCT.md).
-
-
## License
[![License](https://img.shields.io/github/license/bitcoin-sv/spv-wallet-go-client.svg?style=flat&v=2)](LICENSE)
diff --git a/access_keys_test.go b/access_keys_test.go
deleted file mode 100644
index 42732a8e..00000000
--- a/access_keys_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// Package walletclient here we are testing walletclient public methods
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/stretchr/testify/require"
-)
-
-// TestAccessKeys will test the AccessKey methods
-func TestAccessKeys(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/v1/access-key":
- switch r.Method {
- case http.MethodGet, http.MethodPost, http.MethodDelete:
- json.NewEncoder(w).Encode(fixtures.AccessKey)
- }
- case "/v1/access-key/search":
- json.NewEncoder(w).Encode([]*models.AccessKey{fixtures.AccessKey})
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
-
- client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString)
- require.NoError(t, err)
- require.NotNil(t, client.accessKey)
-
- t.Run("GetAccessKey", func(t *testing.T) {
- accessKey, err := client.GetAccessKey(context.Background(), fixtures.AccessKey.ID)
- require.NoError(t, err)
- require.Equal(t, fixtures.AccessKey, accessKey)
- })
-
- t.Run("GetAccessKeys", func(t *testing.T) {
- accessKeys, err := client.GetAccessKeys(context.Background(), nil, nil, nil)
- require.NoError(t, err)
- require.Equal(t, []*models.AccessKey{fixtures.AccessKey}, accessKeys)
- })
-
- t.Run("CreateAccessKey", func(t *testing.T) {
- accessKey, err := client.CreateAccessKey(context.Background(), nil)
- require.NoError(t, err)
- require.Equal(t, fixtures.AccessKey, accessKey)
- })
-
- t.Run("RevokeAccessKey", func(t *testing.T) {
- accessKey, err := client.RevokeAccessKey(context.Background(), fixtures.AccessKey.ID)
- require.NoError(t, err)
- require.Equal(t, fixtures.AccessKey, accessKey)
- })
-}
diff --git a/admin_api.go b/admin_api.go
new file mode 100644
index 00000000..6e63f957
--- /dev/null
+++ b/admin_api.go
@@ -0,0 +1,425 @@
+package spvwallet
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/accesskeys"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/contacts"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/invitations"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/paymails"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/stats"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/status"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/transactions"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/utxos"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/webhooks"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/xpubs"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/configs"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/constants"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/restyutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+// AdminAPI provides a simplified interface for interacting with admin-related APIs.
+// It abstracts the complexities of making HTTP requests and handling responses,
+// allowing developers to easily interact with admin API endpoints.
+//
+// A zero-value AdminAPI is not usable. Use the NewAdminAPI function to create
+// a properly initialized instance.
+//
+// Methods may return wrapped errors, including models.SPVError or
+// ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API.
+type AdminAPI struct {
+ configsAPI *configs.API
+ xpubsAPI *xpubs.API
+ paymailsAPI *paymails.API
+ accessKeyAPI *accesskeys.API
+ transactionsAPI *transactions.API
+ utxosAPI *utxos.API
+ contactsAPI *contacts.API
+ invitationsAPI *invitations.API
+ webhooksAPI *webhooks.API
+ statusAPI *status.API
+ statsAPI *stats.API
+}
+
+// SharedConfig retrieves the shared configuration via the configurations API.
+// The response is unmarshaled into a response.SharedConfig.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) SharedConfig(ctx context.Context) (*response.SharedConfig, error) {
+ res, err := a.configsAPI.SharedConfig(ctx)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminSharedConfigAPI, "retrieve shared configuration", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// CreateXPub creates a new XPub record via the Admin XPubs API.
+// The provided command contains the necessary parameters to define the XPub record.
+//
+// The API response is unmarshaled into a *response.Xpub struct.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (a *AdminAPI) CreateXPub(ctx context.Context, cmd *commands.CreateUserXpub) (*response.Xpub, error) {
+ res, err := a.xpubsAPI.CreateXPub(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminXPubsAPI, "create XPub", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// XPubs retrieves a paginated list of user XPubs via the Admin XPubs API.
+// The response includes user XPubs along with pagination metadata, such as
+// the current page number, sort order, and the field used for sorting (sortBy).
+//
+// Query parameters can be configured using optional query options. These options allow
+// filtering based on metadata, pagination settings, or specific XPub attributes.
+//
+// The API response is unmarshaled into a *queries.XPubPage struct.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (a *AdminAPI) XPubs(ctx context.Context, opts ...queries.QueryOption[filter.XpubFilter]) (*queries.XPubPage, error) {
+ res, err := a.xpubsAPI.XPubs(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminXPubsAPI, "retrieve XPubs page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// CreateContact creates a new contact record via the Admin Contacts API.
+// It accepts a command containing the necessary parameters to define the contact record,
+// such as the creator's paymail, contact's full name, paymail and any associated metadata.
+//
+// The method sends a request to the Contacts API to create the contact, and the API
+// response is unmarshaled into a *response.Contact struct, representing the newly created contact.
+//
+// If the API request fails or the response cannot be decoded, an error is returned. The error is
+// wrapped with additional context to assist in troubleshooting.
+func (a *AdminAPI) CreateContact(ctx context.Context, cmd *commands.CreateContact) (*response.Contact, error) {
+ res, err := a.contactsAPI.CreateContact(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, "create user contacts page", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// Contacts retrieves a paginated list of user contacts from the admin contacts API.
+//
+// The response includes contact data along with pagination details, such as the
+// current page, sort order, and sortBy field. Optional query parameters can be
+// provided using query options. The result is unmarshaled into a *queries.ContactsPage.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (a *AdminAPI) Contacts(ctx context.Context, opts ...queries.QueryOption[filter.AdminContactFilter]) (*queries.ContactsPage, error) {
+ res, err := a.contactsAPI.Contacts(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, "retrieve user contacts page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// ContactUpdate updates a user's contact information through the admin contacts API.
+//
+// This method uses the `UpdateContact` command to specify the details of the contact to update.
+// It sends the update request to the API, unmarshals the response into a `*response.Contact`,
+// and returns the updated contact. If the API request fails or the response cannot be decoded,
+// an error is returned.
+func (a *AdminAPI) ContactUpdate(ctx context.Context, cmd *commands.UpdateContact) (*response.Contact, error) {
+ res, err := a.contactsAPI.UpdateContact(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("update contact with ID: %s", cmd.ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, msg, err).FormatPutErr()
+ }
+
+ return res, nil
+}
+
+// DeleteContact deletes a user contact with the given ID via the admin contacts API.
+// Returns an error if the API request fails or the response cannot be decoded.
+// A nil error indicates the deleting contact was successful.
+func (a *AdminAPI) DeleteContact(ctx context.Context, ID string) error {
+ err := a.contactsAPI.DeleteContact(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("delete contact with ID: %s", ID)
+ return errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, msg, err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// ConfirmContacts confirms contact between two users given their paymails in a request body.
+// Returns an error if the API fails to confirm both contacts.
+// A nil error indicates the confirmation was successful.
+func (a *AdminAPI) ConfirmContacts(ctx context.Context, cmd *commands.ConfirmContacts) error {
+ err := a.contactsAPI.ConfirmContacts(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("confirm contacts: %s & %s", cmd.PaymailA, cmd.PaymailB)
+ return errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, msg, err).FormatPostErr()
+ }
+
+ return nil
+}
+
+// AcceptInvitation processes and accepts a user contact invitation using the given ID via the admin invitations API.
+// Returns an error if the API request fails. A nil error indicates the invitation was successfully accepted.
+func (a *AdminAPI) AcceptInvitation(ctx context.Context, ID string) error {
+ err := a.invitationsAPI.AcceptInvitation(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("accept invitation with ID: %s", ID)
+ return errutil.NewHTTPErrorFormatter(constants.AdminInvitationsAPI, msg, err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// RejectInvitation processes and rejects a user contact invitation using the given ID via the admin invitations API.
+// Returns an error if the API request fails. A nil error indicates the invitation was successfully rejected.
+func (a *AdminAPI) RejectInvitation(ctx context.Context, ID string) error {
+ err := a.invitationsAPI.RejectInvitation(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("delete invitation with ID: %s", ID)
+ return errutil.NewHTTPErrorFormatter(constants.AdminInvitationsAPI, msg, err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// Transactions retrieves a paginated list of transactions via the Admin transactions API.
+// The returned response includes transactions and pagination details, such as the page number,
+// sort order, and sorting field (sortBy).
+//
+// This method allows optional query parameters to be applied via the provided query options.
+// The response is expected to be to unmarshal into a *queries.TransactionPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) Transactions(ctx context.Context, opts ...queries.QueryOption[filter.AdminTransactionFilter]) (*queries.TransactionPage, error) {
+ res, err := a.transactionsAPI.Transactions(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminTransactionsAPI, "retrieve transactions page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// Transaction retrieves a specific transaction by its ID via the Admin transactions API.
+// The response is expected to be unmarshaled into a *response.Transaction struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) Transaction(ctx context.Context, ID string) (*response.Transaction, error) {
+ res, err := a.transactionsAPI.Transaction(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("retrieve a transaction with ID: %s", ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminTransactionsAPI, msg, err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// AccessKeys retrieves a paginated list of access keys via the Admin XPubs API.
+// The response includes access keys and pagination details, such as the page number,
+// sort order, and sorting field (sortBy).
+//
+// This method allows optional query parameters to be applied via the provided query options.
+// The response is expected to unmarshal into a *queries.AccessKeyPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) AccessKeys(ctx context.Context, opts ...queries.QueryOption[filter.AdminAccessKeyFilter]) (*queries.AccessKeyPage, error) {
+ res, err := a.accessKeyAPI.AccessKeys(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminAccessKeyAPI, "retrieve access keys page ", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// SubscribeWebhook registers a webhook subscription using the Admin Webhooks API.
+// The provided command contains the parameters required to define the webhook subscription.
+// Accepts context for controlling cancellation and timeout for the API request.
+// The CreateWebhookSubscription command includes the webhook URL and authentication details.
+// Returns a formatted error if the API request fails. A nil error indicates the webhook subscription was successful.
+func (a *AdminAPI) SubscribeWebhook(ctx context.Context, cmd *commands.CreateWebhookSubscription) error {
+ err := a.webhooksAPI.SubscribeWebhook(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("subscribe webhook URL address: %s", cmd.URL)
+ return errutil.NewHTTPErrorFormatter(constants.AdminWebhooksAPI, msg, err).FormatPostErr()
+ }
+
+ return nil
+}
+
+// UnsubscribeWebhook removes a webhook subscription using the Admin Webhooks API.
+// Accepts the context for controlling cancellation and timeout for the API request.
+// CancelWebhookSubscription command specifies the webhook URL to be unsubscribed.
+// Returns a formatted error if the API request fails. A nil error indicates the webhook subscription was successfully deleted.
+func (a *AdminAPI) UnsubscribeWebhook(ctx context.Context, cmd *commands.CancelWebhookSubscription) error {
+ err := a.webhooksAPI.UnsubscribeWebhook(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("unsubscribe webhook URL address: %s", cmd.URL)
+ return errutil.NewHTTPErrorFormatter(constants.AdminWebhooksAPI, msg, err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// UTXOs fetches a paginated list of UTXOs via the Admin XPubs API.
+// The response includes UTXOs along with pagination details, such as page number,
+// sort order, and sorting field.
+//
+// Optional query parameters can be applied using the provided query options.
+// The response is unmarshaled into a *queries.UtxosPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) UTXOs(ctx context.Context, opts ...queries.QueryOption[filter.AdminUtxoFilter]) (*queries.UtxosPage, error) {
+ res, err := a.utxosAPI.UTXOs(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminUtxosAPI, "retrieve utxos page ", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// Paymails retrieves a paginated list of paymail addresses via the Admin Paymails API.
+// The response includes user paymails along with pagination metadata, such as
+// the current page number, sort order, and the field used for sorting (sortBy).
+//
+// Query parameters can be configured using optional query options. These options allow
+// filtering based on metadata, pagination settings, or specific paymail attributes.
+//
+// The API response is unmarshaled into a *queries.PaymailsPage struct.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (a *AdminAPI) Paymails(ctx context.Context, opts ...queries.QueryOption[filter.AdminPaymailFilter]) (*queries.PaymailsPage, error) {
+ res, err := a.paymailsAPI.Paymails(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminPaymailAPI, "retrieve paymail addresses page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// Paymail retrieves the paymail address associated with the specified ID via the Admin Paymails API.
+// The response is expected to be unmarshaled into a *response.PaymailAddress struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (a *AdminAPI) Paymail(ctx context.Context, ID string) (*response.PaymailAddress, error) {
+ res, err := a.paymailsAPI.Paymail(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("retrieve paymail address with ID: %s", ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminPaymailAPI, msg, err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// CreatePaymail creates a new paymail address record via the Admin Paymails API.
+// The provided command contains the necessary parameters to define the paymail address record.
+//
+// The API response is unmarshaled into a *response.Xpub PaymailAddress.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (a *AdminAPI) CreatePaymail(ctx context.Context, cmd *commands.CreatePaymail) (*response.PaymailAddress, error) {
+ res, err := a.paymailsAPI.CreatePaymail(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminPaymailAPI, "create paymail address", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// DeletePaymail deletes a paymail address with via the Admin Paymails API.
+// It returns an error if the API request fails. A nil error indicates that the paymail
+// was successfully deleted.
+func (a *AdminAPI) DeletePaymail(ctx context.Context, address string) error {
+ err := a.paymailsAPI.DeletePaymail(ctx, address)
+ if err != nil {
+ msg := fmt.Sprintf("remove paymail address: %s", address)
+ return errutil.NewHTTPErrorFormatter(constants.AdminPaymailAPI, msg, err).FormatGetErr()
+ }
+
+ return nil
+}
+
+// Stats retrieves information about the login status via the Admin XPubs API.
+// It accepts a context for controlling cancellation and timeout of the API request.
+// The response is expected to be unmarshaled into a *models.AdminStats struct.
+// A nil error with a valid response indicates the request was successful.
+// Returns a formatted error if the API request fails.
+func (a *AdminAPI) Stats(ctx context.Context) (*models.AdminStats, error) {
+ res, err := a.statsAPI.Stats(ctx)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminStatsAPI, "retrieve stats", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// Status retrieves information about the key type used during the authentication phase.
+// If the key corresponds to the admin key, the method returns true with a nil error.
+// Otherwise, it returns false with a nil error, indicating that the key used does not match
+// the SPV Wallet API admin key. A non-nil error is returned if the API request fails.
+func (a *AdminAPI) Status(ctx context.Context) (bool, error) {
+ ok, err := a.statusAPI.Status(ctx)
+ if err != nil {
+ return false, errutil.NewHTTPErrorFormatter(constants.AdminStatusAPI, "retrieve information about the used key type: %w", err).FormatGetErr()
+ }
+
+ return ok, nil
+}
+
+// NewAdminAPIWithXPriv initializes a new AdminAPI instance using an extended private key (xPriv).
+// This function configures the API client with the provided configuration and uses the xPriv key for authentication.
+// If any step fails, an appropriate error is returned.
+//
+// Note: Requests made with this instance will be securely signed.
+func NewAdminAPIWithXPriv(cfg config.Config, xPriv string) (*AdminAPI, error) {
+ authenticator, err := auth.NewXprivAuthenticator(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize xPriv authenticator: %w", err)
+ }
+
+ return initAdminAPI(cfg, authenticator)
+}
+
+// NewAdminAPIWithXPub initializes a new AdminAPI instance using an extended public key (xPub).
+// This function configures the API client with the provided configuration and uses the xPub key for authentication.
+// If any configuration or initialization step fails, an appropriate error is returned.
+//
+// Note: Requests made with this instance will not be signed.
+// For enhanced security, it is strongly recommended to use `NewAdminAPIWithXPriv` instead.
+func NewAdminAPIWithXPub(cfg config.Config, xPub string) (*AdminAPI, error) {
+ authenticator, err := auth.NewXpubOnlyAuthenticator(xPub)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize xPub authenticator: %w", err)
+ }
+
+ return initAdminAPI(cfg, authenticator)
+}
+
+func initAdminAPI(cfg config.Config, auth authenticator) (*AdminAPI, error) {
+ url, err := url.Parse(cfg.Addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err)
+ }
+
+ httpClient := restyutil.NewHTTPClient(cfg, auth)
+ if httpClient == nil {
+ return nil, fmt.Errorf("failed to initialize HTTP client - nil value")
+ }
+
+ return &AdminAPI{
+ configsAPI: configs.NewAPI(url, httpClient),
+ paymailsAPI: paymails.NewAPI(url, httpClient),
+ transactionsAPI: transactions.NewAPI(url, httpClient),
+ xpubsAPI: xpubs.NewAPI(url, httpClient),
+ utxosAPI: utxos.NewAPI(url, httpClient),
+ accessKeyAPI: accesskeys.NewAPI(url, httpClient),
+ webhooksAPI: webhooks.NewAPI(url, httpClient),
+ contactsAPI: contacts.NewAPI(url, httpClient),
+ invitationsAPI: invitations.NewAPI(url, httpClient),
+ statusAPI: status.NewAPI(url, httpClient),
+ statsAPI: stats.NewAPI(url, httpClient),
+ }, nil
+}
diff --git a/admin_contacts_test.go b/admin_contacts_test.go
deleted file mode 100644
index ea0381a7..00000000
--- a/admin_contacts_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- responsemodels "github.com/bitcoin-sv/spv-wallet/models/response"
- "github.com/stretchr/testify/require"
-)
-
-// TestAdminContactActions testing Admin contacts methods
-func TestAdminContactActions(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.URL.Path == "/v1/admin/contact/search" && r.Method == http.MethodPost:
- c := fixtures.Contact
- c.ID = "1"
- content := models.PagedResponse[*models.Contact]{
- Content: []*models.Contact{c},
- }
- json.NewEncoder(w).Encode(content)
- case r.URL.Path == "/v1/admin/contact/1" && r.Method == http.MethodPatch:
- contact := fixtures.Contact
- json.NewEncoder(w).Encode(contact)
- case r.URL.Path == "/v1/admin/contact/1" && r.Method == http.MethodDelete:
- w.WriteHeader(http.StatusOK)
- case r.URL.Path == "/v1/admin/contact/accepted/1" && r.Method == http.MethodPatch:
- contact := fixtures.Contact
- contact.Status = responsemodels.ContactNotConfirmed
- json.NewEncoder(w).Encode(contact)
- case r.URL.Path == "/v1/admin/contact/rejected/1" && r.Method == http.MethodPatch:
- contact := fixtures.Contact
- contact.Status = responsemodels.ContactRejected
- json.NewEncoder(w).Encode(contact)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
-
- client, err := NewWithAdminKey(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
- require.NotNil(t, client.adminXPriv)
-
- t.Run("AdminGetContacts", func(t *testing.T) {
- contacts, err := client.AdminGetContacts(context.Background(), nil, nil, nil)
- require.NoError(t, err)
- require.Equal(t, "1", contacts.Content[0].ID)
- })
-
- t.Run("AdminUpdateContact", func(t *testing.T) {
- contact, err := client.AdminUpdateContact(context.Background(), "1", "Jane Doe", nil)
- require.NoError(t, err)
- require.Equal(t, "Test User", contact.FullName)
- })
-
- t.Run("AdminDeleteContact", func(t *testing.T) {
- err := client.AdminDeleteContact(context.Background(), "1")
- require.NoError(t, err)
- })
-
- t.Run("AdminAcceptContact", func(t *testing.T) {
- contact, err := client.AdminAcceptContact(context.Background(), "1")
- require.NoError(t, err)
- require.Equal(t, responsemodels.ContactNotConfirmed, contact.Status)
- })
-
- t.Run("AdminRejectContact", func(t *testing.T) {
- contact, err := client.AdminRejectContact(context.Background(), "1")
- require.NoError(t, err)
- require.Equal(t, responsemodels.ContactRejected, contact.Status)
- })
-}
diff --git a/authentication.go b/authentication.go
deleted file mode 100644
index 1a777196..00000000
--- a/authentication.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package walletclient
-
-import (
- "encoding/base64"
- "fmt"
- "net/http"
- "time"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- bsm "github.com/bitcoin-sv/go-sdk/compat/bsm"
- ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
- script "github.com/bitcoin-sv/go-sdk/script"
- trx "github.com/bitcoin-sv/go-sdk/transaction"
- sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash"
- "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/utils"
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-// SetSignature will set the signature on the header for the request
-func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error {
- // Create the signature
- authData, err := createSignature(xPriv, bodyString)
- if err != nil {
- return WrapError(err)
- }
-
- // Set the auth header
- header.Set(models.AuthHeader, authData.XPub)
-
- setSignatureHeaders(header, authData)
-
- return nil
-}
-
-// GetSignedHex will sign all the inputs using the given xPriv key
-func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) {
- // Create transaction from hex
- tx, err := trx.NewTransactionFromHex(dt.Hex)
-
- // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign
- tx.Inputs = make([]*trx.TransactionInput, 0)
- if err != nil {
- return "", err
- }
-
- // Enrich inputs
- for _, draftInput := range dt.Configuration.Inputs {
- lockingScript, err := prepareLockingScript(&draftInput.Destination)
- if err != nil {
- return "", err
- }
-
- unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination)
- if err != nil {
- return "", err
- }
-
- tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript)
- }
-
- tx.Sign()
-
- return tx.String(), nil
-}
-
-func prepareLockingScript(dst *models.Destination) (*script.Script, error) {
- lockingScript, err := script.NewFromHex(dst.LockingScript)
- if err != nil {
- return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err)
- }
-
- return lockingScript, nil
-}
-
-func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) {
- key, err := getDerivedKeyForDestination(xPriv, dst)
- if err != nil {
- return nil, err
- }
-
- return getUnlockingScript(key)
-}
-
-func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) {
- // Derive the child key (m/chain/num)
- derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num)
- if err != nil {
- return nil, err
- }
-
- // Handle paymail destination derivation if applicable
- if dst.PaymailExternalDerivationNum != nil {
- derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum)
- if err != nil {
- return nil, err
- }
- }
-
- // Get the private key from the derived key
- return bip32.GetPrivateKeyFromHDKey(derivedKey)
-}
-
-// Generate unlocking script using private key
-func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) {
- sigHashFlags := sighash.AllForkID
- return p2pkh.Unlock(privateKey, &sigHashFlags)
-}
-
-// createSignature will create a signature for the given key & body contents
-func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *models.AuthPayload, err error) {
- // No key?
- if xPriv == nil {
- err = ErrMissingXpriv
- return
- }
-
- // Get the xPub
- payload = new(models.AuthPayload)
- if payload.XPub, err = bip32.GetExtendedPublicKey(
- xPriv,
- ); err != nil { // Should never error if key is correct
- return
- }
-
- // auth_nonce is a random unique string to seed the signing message
- // this can be checked server side to make sure the request is not being replayed
- if payload.AuthNonce, err = utils.RandomHex(32); err != nil { // Should never error if key is correct
- return
- }
-
- // Derive the address for signing
- var key *bip32.ExtendedKey
- if key, err = utils.DeriveChildKeyFromHex(
- xPriv, payload.AuthNonce,
- ); err != nil {
- return
- }
-
- var privateKey *ec.PrivateKey
- if privateKey, err = bip32.GetPrivateKeyFromHDKey(key); err != nil {
- return // Should never error if key is correct
- }
-
- return createSignatureCommon(payload, bodyString, privateKey)
-}
-
-// createSignatureCommon will create a signature
-func createSignatureCommon(payload *models.AuthPayload, bodyString string, privateKey *ec.PrivateKey) (*models.AuthPayload, error) {
- // Create the auth header hash
- payload.AuthHash = utils.Hash(bodyString)
-
- // auth_time is the current time and makes sure a request can not be sent after 30 secs
- payload.AuthTime = time.Now().UnixMilli()
-
- key := payload.XPub
- if key == "" && payload.AccessKey != "" {
- key = payload.AccessKey
- }
-
- // Signature, using bitcoin signMessage
- sigBytes, err := bsm.SignMessage(
- privateKey,
- getSigningMessage(key, payload),
- )
- if err != nil {
- return nil, err
- }
-
- payload.Signature = base64.StdEncoding.EncodeToString(sigBytes)
-
- return payload, nil
-}
-
-// getSigningMessage will build the signing message byte array
-func getSigningMessage(xPub string, auth *models.AuthPayload) []byte {
- message := fmt.Sprintf("%s%s%s%d", xPub, auth.AuthHash, auth.AuthNonce, auth.AuthTime)
- return []byte(message)
-}
-
-func setSignatureHeaders(header *http.Header, authData *models.AuthPayload) {
- // Create the auth header hash
- header.Set(models.AuthHeaderHash, authData.AuthHash)
-
- // Set the nonce
- header.Set(models.AuthHeaderNonce, authData.AuthNonce)
-
- // Set the time
- header.Set(models.AuthHeaderTime, fmt.Sprintf("%d", authData.AuthTime))
-
- // Set the signature
- header.Set(models.AuthSignature, authData.Signature)
-}
diff --git a/client_options.go b/client_options.go
deleted file mode 100644
index 9e2df8bc..00000000
--- a/client_options.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package walletclient
-
-import (
- "fmt"
- "net/http"
- "net/url"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
-)
-
-// configurator is the interface for configuring WalletClient
-type configurator interface {
- Configure(c *WalletClient) error
-}
-
-// xPrivConf sets the xPrivString field of a WalletClient
-type xPrivConf struct {
- XPrivString string
-}
-
-func (w *xPrivConf) Configure(c *WalletClient) error {
- var err error
- if c.xPriv, err = bip32.GenerateHDKeyFromString(w.XPrivString); err != nil {
- c.xPriv = nil
- return ErrInvalidXpriv.Wrap(err)
- }
- return nil
-}
-
-// xPubConf sets the xPubString on the client
-type xPubConf struct {
- XPubString string
-}
-
-func (w *xPubConf) Configure(c *WalletClient) error {
- var err error
- if c.xPub, err = bip32.GetHDKeyFromExtendedPublicKey(w.XPubString); err != nil {
- c.xPub = nil
- return ErrInvalidXpub.Wrap(err)
- }
- return nil
-}
-
-// accessKeyConf sets the accessKeyString on the client
-type accessKeyConf struct {
- AccessKeyString string
-}
-
-func (w *accessKeyConf) Configure(c *WalletClient) error {
- var err error
- if c.accessKey, err = w.initializeAccessKey(); err != nil {
- c.accessKey = nil
- return err
- }
- return nil
-}
-
-func (w *accessKeyConf) initializeAccessKey() (*ec.PrivateKey, error) {
- var errPriv, errPub error
- privateKey, errPriv := ec.PrivateKeyFromWif(w.AccessKeyString)
- if errPriv != nil {
- privateKey, errPub = ec.PrivateKeyFromHex(w.AccessKeyString)
- if privateKey == nil {
- return nil, ErrInvalidAccessKey.Wrap(errPriv).Wrap(errPub)
- }
- }
-
- return privateKey, nil
-}
-
-// adminKeyConf sets the admin key for creating new xpubs
-type adminKeyConf struct {
- AdminKeyString string
-}
-
-func (w *adminKeyConf) Configure(c *WalletClient) error {
- var err error
- c.adminXPriv, err = bip32.GenerateHDKeyFromString(w.AdminKeyString)
- if err != nil {
- c.adminXPriv = nil
- return ErrInvalidAdminKey.Wrap(err)
- }
- return nil
-}
-
-// httpConf sets the URL and httpConf client of a WalletClient
-type httpConf struct {
- ServerURL string
- HTTPClient *http.Client
-}
-
-func (w *httpConf) Configure(c *WalletClient) error {
- // Ensure the ServerURL ends with a clean base URL
- baseURL, err := validateAndCleanURL(w.ServerURL)
- if err != nil {
- return ErrInvalidServerURL.Wrap(err)
- }
-
- const basePath = "/v1"
- c.server = fmt.Sprintf("%s%s", baseURL, basePath)
-
- c.httpClient = w.HTTPClient
- if w.HTTPClient != nil {
- c.httpClient = w.HTTPClient
- } else {
- c.httpClient = http.DefaultClient
- }
- return nil
-}
-
-// signRequest configures whether to sign HTTP requests
-type signRequest struct {
- Sign bool
-}
-
-func (w *signRequest) Configure(c *WalletClient) error {
- c.signRequest = w.Sign
- return nil
-}
-
-// validateAndCleanURL ensures that the provided URL is valid, and strips it down to just the base URL.
-func validateAndCleanURL(rawURL string) (string, error) {
- if rawURL == "" {
- return "", fmt.Errorf("empty URL")
- }
-
- // Parse the URL to validate it
- parsedURL, err := url.Parse(rawURL)
- if err != nil {
- return "", fmt.Errorf("parsing URL failed: %w", err)
- }
-
- // Rebuild the URL with only the scheme and host (and port if included)
- cleanedURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
-
- if parsedURL.Path == "" || parsedURL.Path == "/" {
- return cleanedURL, nil
- }
-
- return cleanedURL, nil
-}
diff --git a/client_options_test.go b/client_options_test.go
deleted file mode 100644
index 5627afa3..00000000
--- a/client_options_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package walletclient
-
-import "testing"
-
-func TestValidateAndCleanURL(t *testing.T) {
- tests := []struct {
- name string
- rawURL string
- expected string
- wantErr bool
- }{
- {"Empty URL", "", "", true},
- {"Valid URL with path", "http://example.com/path", "http://example.com", false},
- {"Valid URL without path", "http://example.com", "http://example.com", false},
- {"Valid URL with port", "http://example.com:8080", "http://example.com:8080", false},
- {"Invalid URL", "http://%41:8080/", "", true},
- {"HTTPS URL", "https://example.com", "https://example.com", false},
- {"HTTPS URL with path", "https://example.com/path", "https://example.com", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := validateAndCleanURL(tt.rawURL)
- if (err != nil) != tt.wantErr {
- t.Errorf("validateAndCleanURL() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
- if got != tt.expected {
- t.Errorf("validateAndCleanURL() = %v, expected %v", got, tt.expected)
- }
- })
- }
-}
diff --git a/codecov.yml b/codecov.yml
index 21745834..56c50c43 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -39,4 +39,4 @@ parsers:
comment:
layout: "reach,diff,flags,files,footer"
behavior: default
- require_changes: false
\ No newline at end of file
+ require_changes: false
diff --git a/commands/contacts.go b/commands/contacts.go
new file mode 100644
index 00000000..f3251bcb
--- /dev/null
+++ b/commands/contacts.go
@@ -0,0 +1,46 @@
+package commands
+
+// UpsertContact holds the necessary arguments for adding or updating a user's contact information.
+type UpsertContact struct {
+ ContactPaymail string `json:"-"` // Paymail address of the new/updating contact.
+ FullName string `json:"fullName"` // The full name of the user.
+ Metadata map[string]any `json:"metadata"` // Metadata associated with the contact.
+ RequesterPaymail string `json:"requesterPaymail"` // RequesterPaymail address of the user, which is used for secure and simplified payment transfers.
+}
+
+// UpdateContact represents the arguments defined for updating a user's contact information.
+//
+// Note: The `ID` field is not included in the request body sent to the SPV Wallet API.
+// Instead, it is used as part of the endpoint path (e.g., /api/v1/admin/contacts/{ID}).
+type UpdateContact struct {
+ ID string `json:"-"` // Unique identifier of the contact to be updated.
+ FullName string `json:"fullName"` // The full name of the contact.
+ Metadata map[string]any `json:"metadata"` // Metadata associated with the contact.
+}
+
+// ConfirmContacts represents the body defined for confirming contact's between two users.
+type ConfirmContacts struct {
+ PaymailA string `json:"paymailA"` // The paymail address of the first user in the contact relationship
+ PaymailB string `json:"paymailB"` // The paymail address of the second user in the contact relationship
+}
+
+// CreateContact holds the necessary arguments for creating a contact in the SPV Wallet system.
+// It includes the paymail of the creator (the user initiating the contact addition),
+// the full name of the contact, and any associated metadata.
+// Note: The `Paymail` field is not included in the request body sent to the SPV Wallet API.
+// Instead, it is used as part of the endpoint path (e.g., /api/v1/admin/contacts/{paymail}).
+type CreateContact struct {
+ // CreatorPaymail is the paymail address of the user who is adding the contact.
+ // It identifies the owner or creator of the contact.
+ CreatorPaymail string `json:"creatorPaymail"`
+
+ Paymail string `json:"-"` // Paymail identifier of the contact to be created.
+
+ // FullName is the full name of the contact to be added.
+ // This is the name that will be associated with the contact in the system.
+ FullName string `json:"fullName"`
+
+ // Metadata is additional information that can be associated with the contact.
+ // It is a key-value pair where the value can be of any type.
+ Metadata map[string]any `json:"metadata"`
+}
diff --git a/commands/paymails.go b/commands/paymails.go
new file mode 100644
index 00000000..91adee99
--- /dev/null
+++ b/commands/paymails.go
@@ -0,0 +1,13 @@
+package commands
+
+import "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+
+// CreatePaymail defines the parameters required to create a new paymail address,
+// including associated metadata such as the public name and avatar.
+type CreatePaymail struct {
+ Metadata queryparams.Metadata `json:"metadata"` // Metadata associated with the paymail as key-value pairs.
+ Key string `json:"key"` // The xpub key linked to the paymail.
+ Address string `json:"address"` // The paymail address to be created.
+ PublicName string `json:"public_name"` // The public display name associated with the paymail.
+ Avatar string `json:"avatar"` // The URL of the paymail's avatar image.
+}
diff --git a/commands/transactions.go b/commands/transactions.go
new file mode 100644
index 00000000..06334073
--- /dev/null
+++ b/commands/transactions.go
@@ -0,0 +1,43 @@
+package commands
+
+import (
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+// RecordTransaction holds the arguments required to record a user transaction.
+type RecordTransaction struct {
+ Metadata queryparams.Metadata `json:"metadata"` // Metadata associated with the transaction.
+ Hex string `json:"hex"` // Hexadecimal string representation of the transaction.
+ ReferenceID string `json:"referenceId"` // Reference ID for the transaction.
+}
+
+// DraftTransaction holds the arguments required to create user draft transaction.
+type DraftTransaction struct {
+ Config response.TransactionConfig `json:"config"` // Configuration for the transaction.
+ Metadata queryparams.Metadata `json:"metadata"` // Metadata related to the transaction.
+}
+
+// UpdateTransactionMetadata holds the arguments required to update the metadata of a user transaction.
+// The ID field is ignored in the request body sent to the SPV Wallet API; instead, it is used as part
+// of the transaction metadata update endpoint (e.g., /api/v1/transactions/{ID}).
+type UpdateTransactionMetadata struct {
+ ID string `json:"-"` // Unique identifier of the transaction to be updated.
+ Metadata queryparams.Metadata `json:"metadata"` // New metadata to associate with the transaction.
+}
+
+// Recipients represents a single recipient in a transaction.
+// It includes details about the recipient address, the amount to send,
+// and an optional OP_RETURN script for including additional data in the transaction.
+type Recipients struct {
+ OpReturn *response.OpReturn `json:"op_return"` // Optional OP_RETURN script for attaching data to the transaction.
+ Satoshis uint64 `json:"satoshis"` // Amount to send to the recipient, in satoshis.
+ To string `json:"to"` // Paymails address of the recipient.
+}
+
+// SendToRecipients holds the arguments required to send a transaction to multiple recipients.
+// This includes the list of recipients with their details and optional metadata for the transaction.
+type SendToRecipients struct {
+ Recipients []*Recipients `json:"recipients"` // List of recipients for the transaction.
+ Metadata queryparams.Metadata `json:"metadata"` // Metadata associated with the transaction.
+}
diff --git a/commands/users.go b/commands/users.go
new file mode 100644
index 00000000..90efeb49
--- /dev/null
+++ b/commands/users.go
@@ -0,0 +1,15 @@
+package commands
+
+import "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+
+// UpdateXPubMetadata contains the parameters needed to update the metadata
+// associated with the current user's xpub.
+type UpdateXPubMetadata struct {
+ Metadata queryparams.Metadata `json:"metadata"` // Key-value pairs representing the xpub metadata
+}
+
+// GenerateAccessKey contains the parameters needed to generate a new access key
+// for the current user, including any associated metadata.
+type GenerateAccessKey struct {
+ Metadata queryparams.Metadata `json:"metadata"` // Key-value pairs representing the access key metadata
+}
diff --git a/commands/webhooks.go b/commands/webhooks.go
new file mode 100644
index 00000000..27fb71fe
--- /dev/null
+++ b/commands/webhooks.go
@@ -0,0 +1,15 @@
+package commands
+
+// CreateWebhookSubscription holds the arguments required to register a webhook subscription mechanism.
+// This struct is used to define the details necessary for subscribing to webhook events.
+type CreateWebhookSubscription struct {
+ URL string `json:"url"` // The endpoint where webhook events will be sent. This must be a valid and reachable URL.
+ TokenHeader string `json:"tokenHeader"` // The name of the HTTP header used for authentication in the subscription requests.
+ TokenValue string `json:"tokenValue"` // The value of the authentication token that will be included in the TokenHeader.
+}
+
+// CancelWebhookSubscription holds the arguments required to cancel and remove a previously registered webhook subscription.
+// This struct specifies the subscription endpoint that should be canceled.
+type CancelWebhookSubscription struct {
+ URL string `json:"url"` // The endpoint URL of the subscription to be removed. This must match the URL of an existing subscription.
+}
diff --git a/commands/xpub.go b/commands/xpub.go
new file mode 100644
index 00000000..3abbfe7d
--- /dev/null
+++ b/commands/xpub.go
@@ -0,0 +1,9 @@
+package commands
+
+import "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+
+// CreateUserXpub contains the parameters required to register a user's XPub.
+type CreateUserXpub struct {
+ Metadata queryparams.Metadata `json:"metadata"` // Metadata associated with the XPub.
+ XPub string `json:"key"` // The user's XPub key to be recorded.
+}
diff --git a/config.go b/config.go
deleted file mode 100644
index cde6aeb2..00000000
--- a/config.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package walletclient
-
-import "github.com/bitcoin-sv/spv-wallet/models"
-
-// TransportType the type of transport being used ('http' for usage or 'mock' for testing)
-type TransportType string
-
-// SPVWalletUserAgent the spv wallet user agent sent to the spv wallet.
-const SPVWalletUserAgent = "SPVWallet: go-client"
-
-const (
- // SPVWalletTransportHTTP uses the http transport for all spv-wallet actions
- SPVWalletTransportHTTP TransportType = "http"
-
- // SPVWalletTransportMock uses the mock transport for all spv-wallet actions
- SPVWalletTransportMock TransportType = "mock"
-)
-
-// Recipients is a struct for recipients
-type Recipients struct {
- OpReturn *models.OpReturn `json:"op_return"`
- Satoshis uint64 `json:"satoshis"`
- To string `json:"to"`
-}
-
-const (
- // FieldMetadata is the field name for metadata
- FieldMetadata = "metadata"
-
- // FieldQueryParams is the field name for the query params
- FieldQueryParams = "params"
-
- // FieldXpubKey is the field name for xpub key
- FieldXpubKey = "key"
-
- // FieldXpubID is the field name for xpub id
- FieldXpubID = "xpub_id"
-
- // FieldAddress is the field name for paymail address
- FieldAddress = "address"
-
- // FieldPublicName is the field name for (paymail) public name
- FieldPublicName = "public_name"
-
- // FieldAvatar is the field name for (paymail) avatar
- FieldAvatar = "avatar"
-
- // FieldConditions is the field name for conditions
- FieldConditions = "conditions"
-
- // FieldTo is the field name for "to"
- FieldTo = "to"
-
- // FieldSatoshis is the field name for "satoshis"
- FieldSatoshis = "satoshis"
-
- // FieldOpReturn is the field name for "op_return"
- FieldOpReturn = "op_return"
-
- // FieldConfig is the field name for "config"
- FieldConfig = "config"
-
- // FieldOutputs is the field name for "outputs"
- FieldOutputs = "outputs"
-
- // FieldHex is the field name for "hex"
- FieldHex = "hex"
-
- // FieldReferenceID is the field name for "reference_id"
- FieldReferenceID = "reference_id"
-
- // FieldID is the id field for most models
- FieldID = "id"
-
- // FieldLockingScript is the field for locking script
- FieldLockingScript = "locking_script"
-
- // FieldUserAgent is the field for storing the user agent
- FieldUserAgent = "user_agent"
-
- // FieldTransactionConfig is the field for the config of a new transaction
- FieldTransactionConfig = "transaction_config"
-
- // FieldTransactionID is the field for transaction ID
- FieldTransactionID = "tx_id"
-
- // FieldOutputIndex is the field for "output_index"
- FieldOutputIndex = "output_index"
-)
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 00000000..134c001c
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+ "log"
+ "net/http"
+ "net/url"
+ "time"
+
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+)
+
+// Config holds configuration settings for establishing a connection and handling
+// request details in the application.
+type Config struct {
+ Addr string // The base address of the SPV Wallet API.
+ Timeout time.Duration // The HTTP requests timeout duration.
+ Transport http.RoundTripper // Custom HTTP transport, allowing optional customization of the HTTP client behavior.
+}
+
+// New creates a new Config instance with optional customizations.
+func New(options ...Option) Config {
+ cfg := Config{}
+ for _, opt := range options {
+ opt(&cfg)
+ }
+ cfg.setDefaultValues()
+ if err := cfg.Validate(); err != nil {
+ log.Fatalf("Error creating configuration: %v", err)
+ }
+ return cfg
+}
+
+// Validate checks the configuration for invalid or missing values.
+func (cfg *Config) Validate() error {
+ if cfg.Addr == "" {
+ return goclienterr.ErrConfigValidationMissingAddress
+ }
+
+ if _, err := url.ParseRequestURI(cfg.Addr); err != nil {
+ return goclienterr.ErrConfigValidationInvalidAddress
+ }
+
+ if cfg.Timeout < 0 {
+ return goclienterr.ErrConfigValidationInvalidTimeout
+ }
+
+ return nil
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 00000000..a7b9a806
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,131 @@
+package config_test
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfig_New(t *testing.T) {
+ transport := &http.Transport{
+ MaxIdleConns: 10,
+ MaxIdleConnsPerHost: 10,
+ Proxy: http.ProxyFromEnvironment,
+ }
+
+ tests := []struct {
+ name string
+ options []config.Option
+ expected config.Config
+ }{
+ {
+ name: "All defaults",
+ options: nil,
+ expected: config.Config{
+ Addr: "http://localhost:3003",
+ Timeout: 1 * time.Minute,
+ Transport: http.DefaultTransport,
+ },
+ },
+ {
+ name: "Partial customization",
+ options: []config.Option{
+ config.WithAddr("http://api.example.com"),
+ },
+ expected: config.Config{
+ Addr: "http://api.example.com",
+ Timeout: 1 * time.Minute,
+ Transport: http.DefaultTransport,
+ },
+ },
+ {
+ name: "Full customization",
+ options: []config.Option{
+ config.WithAddr("http://custom.example.com"),
+ config.WithTimeout(2 * time.Minute),
+ config.WithTransport(transport),
+ },
+ expected: config.Config{
+ Addr: "http://custom.example.com",
+ Timeout: 2 * time.Minute,
+ Transport: transport,
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ cfg := config.New(test.options...)
+ require.Equal(t, test.expected, cfg)
+ })
+ }
+}
+
+func TestConfig_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg config.Config
+ expectedErr error
+ }{
+ {
+ name: "Valid configuration with constructor defaults",
+ cfg: config.New(),
+ expectedErr: nil,
+ },
+ {
+ name: "Valid configuration",
+ cfg: config.Config{
+ Addr: "http://api.example.com",
+ Timeout: 30 * time.Second,
+ Transport: http.DefaultTransport,
+ },
+ expectedErr: nil, // No error expected
+ },
+ {
+ name: "Missing Addr",
+ cfg: config.Config{
+ Timeout: 30 * time.Second,
+ Transport: http.DefaultTransport,
+ },
+ expectedErr: goclienterr.ErrConfigValidationMissingAddress,
+ },
+ {
+ name: "Invalid Addr URL",
+ cfg: config.Config{
+ Addr: "invalid-url",
+ Timeout: 30 * time.Second,
+ Transport: http.DefaultTransport,
+ },
+ expectedErr: goclienterr.ErrConfigValidationInvalidAddress,
+ },
+ {
+ name: "Zero Timeout - default 1m",
+ cfg: config.Config{
+ Addr: "http://api.example.com",
+ Timeout: 0,
+ Transport: http.DefaultTransport,
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "negative Timeout",
+ cfg: config.Config{
+ Addr: "http://api.example.com",
+ Timeout: -10 * time.Second,
+ Transport: http.DefaultTransport,
+ },
+ expectedErr: goclienterr.ErrConfigValidationInvalidTimeout,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := test.cfg.Validate()
+ require.ErrorIs(t, test.expectedErr, err)
+ })
+ }
+}
diff --git a/config/defaults.go b/config/defaults.go
new file mode 100644
index 00000000..9959a3c3
--- /dev/null
+++ b/config/defaults.go
@@ -0,0 +1,26 @@
+package config
+
+import (
+ "net/http"
+ "time"
+)
+
+const (
+ // defaultAddr is the default base address of the SPV Wallet API.
+ defaultAddr string = "http://localhost:3003"
+ // DefaultTimeout is the default HTTP requests timeout duration.
+ defaultTimeout time.Duration = 1 * time.Minute
+)
+
+// setDefaultValues assigns default values to fields that are not explicitly set.
+func (cfg *Config) setDefaultValues() {
+ if cfg.Addr == "" {
+ cfg.Addr = defaultAddr
+ }
+ if cfg.Timeout == 0 {
+ cfg.Timeout = defaultTimeout
+ }
+ if cfg.Transport == nil {
+ cfg.Transport = http.DefaultTransport
+ }
+}
diff --git a/config/options.go b/config/options.go
new file mode 100644
index 00000000..9946d8fd
--- /dev/null
+++ b/config/options.go
@@ -0,0 +1,31 @@
+package config
+
+import (
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Option defines a function signature for modifying a Config.
+type Option func(*Config)
+
+// WithAddr sets the address in the configuration.
+func WithAddr(addr string) Option {
+ return func(cfg *Config) {
+ cfg.Addr = strings.TrimSpace(addr)
+ }
+}
+
+// WithTimeout sets the timeout duration in the configuration.
+func WithTimeout(timeout time.Duration) Option {
+ return func(cfg *Config) {
+ cfg.Timeout = timeout
+ }
+}
+
+// WithTransport sets the HTTP transport in the configuration.
+func WithTransport(transport http.RoundTripper) Option {
+ return func(cfg *Config) {
+ cfg.Transport = transport
+ }
+}
diff --git a/contacts_test.go b/contacts_test.go
deleted file mode 100644
index 5781edad..00000000
--- a/contacts_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- responsemodels "github.com/bitcoin-sv/spv-wallet/models/response"
- "github.com/stretchr/testify/require"
-)
-
-// TestContactActionsRouting will test routing
-func TestContactActionsRouting(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch {
- case strings.HasPrefix(r.URL.Path, "/v1/contact/rejected/"):
- if r.Method == http.MethodPatch {
- json.NewEncoder(w).Encode(map[string]string{"result": "rejected"})
- }
- case r.URL.Path == "/v1/contact/accepted/":
- if r.Method == http.MethodPost {
- json.NewEncoder(w).Encode(map[string]string{"result": string(responsemodels.ContactNotConfirmed)})
- }
- case r.URL.Path == "/v1/contact/search":
- if r.Method == http.MethodPost {
- content := models.PagedResponse[*models.Contact]{
- Content: []*models.Contact{fixtures.Contact},
- }
- json.NewEncoder(w).Encode(content)
- }
- case strings.HasPrefix(r.URL.Path, "/v1/contact/"):
- if r.Method == http.MethodPost || r.Method == http.MethodPut {
- json.NewEncoder(w).Encode(map[string]string{"result": "upserted"})
- }
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
-
- client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString)
- require.NoError(t, err)
- require.NotNil(t, client.accessKey)
-
- t.Run("RejectContact", func(t *testing.T) {
- err := client.RejectContact(context.Background(), fixtures.PaymailAddress)
- require.NoError(t, err)
- })
-
- t.Run("AcceptContact", func(t *testing.T) {
- err := client.AcceptContact(context.Background(), fixtures.PaymailAddress)
- require.NoError(t, err)
- })
-
- t.Run("GetContacts", func(t *testing.T) {
- contacts, err := client.GetContacts(context.Background(), nil, nil, nil)
- require.NoError(t, err)
- require.NotNil(t, contacts)
- })
-
- t.Run("UpsertContact", func(t *testing.T) {
- contact, err := client.UpsertContact(context.Background(), "test-id", "test@paymail.com", "", nil)
- require.NoError(t, err)
- require.NotNil(t, contact)
- })
-
- t.Run("UpsertContactForPaymail", func(t *testing.T) {
- contact, err := client.UpsertContactForPaymail(context.Background(), "test-id", "test@paymail.com", nil, "test@paymail.com")
- require.NoError(t, err)
- require.NotNil(t, contact)
- })
-}
diff --git a/destinations_test.go b/destinations_test.go
deleted file mode 100644
index f117bf22..00000000
--- a/destinations_test.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/bitcoin-sv/spv-wallet/models/filter"
- "github.com/stretchr/testify/require"
-)
-
-func TestDestinations(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sendJSONResponse := func(data interface{}) {
- w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(data); err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- }
- }
-
- const dest = "/v1/destination"
-
- switch {
- case r.URL.Path == "/v1/v1/destination/address/"+fixtures.Destination.Address && r.Method == http.MethodGet:
- sendJSONResponse(fixtures.Destination)
- case r.URL.Path == "/v1/destination/lockingScript/"+fixtures.Destination.LockingScript && r.Method == http.MethodGet:
- sendJSONResponse(fixtures.Destination)
- case r.URL.Path == "/v1/destination/search" && r.Method == http.MethodPost:
- sendJSONResponse([]*models.Destination{fixtures.Destination})
- case r.URL.Path == dest && r.Method == http.MethodGet:
- sendJSONResponse(fixtures.Destination)
- case r.URL.Path == dest && r.Method == http.MethodPatch:
- sendJSONResponse(fixtures.Destination)
- case r.URL.Path == dest && r.Method == http.MethodPost:
- sendJSONResponse(fixtures.Destination)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
- client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString)
- require.NoError(t, err)
- require.NotNil(t, client.accessKey)
-
- t.Run("GetDestinationByID", func(t *testing.T) {
- destination, err := client.GetDestinationByID(context.Background(), fixtures.Destination.ID)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("GetDestinationByAddress", func(t *testing.T) {
- destination, err := client.GetDestinationByAddress(context.Background(), fixtures.Destination.Address)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("GetDestinationByLockingScript", func(t *testing.T) {
- destination, err := client.GetDestinationByLockingScript(context.Background(), fixtures.Destination.LockingScript)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("GetDestinations", func(t *testing.T) {
- destinations, err := client.GetDestinations(context.Background(), &filter.DestinationFilter{}, nil, nil)
- require.NoError(t, err)
- require.Equal(t, []*models.Destination{fixtures.Destination}, destinations)
- })
-
- t.Run("NewDestination", func(t *testing.T) {
- destination, err := client.NewDestination(context.Background(), fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("UpdateDestinationMetadataByID", func(t *testing.T) {
- destination, err := client.UpdateDestinationMetadataByID(context.Background(), fixtures.Destination.ID, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("UpdateDestinationMetadataByAddress", func(t *testing.T) {
- destination, err := client.UpdateDestinationMetadataByAddress(context.Background(), fixtures.Destination.Address, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-
- t.Run("UpdateDestinationMetadataByLockingScript", func(t *testing.T) {
- destination, err := client.UpdateDestinationMetadataByLockingScript(context.Background(), fixtures.Destination.LockingScript, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Destination, destination)
- })
-}
diff --git a/errors.go b/errors.go
deleted file mode 100644
index 277ff343..00000000
--- a/errors.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package walletclient
-
-import (
- "encoding/json"
- "github.com/bitcoin-sv/spv-wallet/models"
- "net/http"
-)
-
-// ErrAdminKey admin key not set
-var ErrAdminKey = models.SPVError{Message: "an admin key must be set to be able to create an xpub", StatusCode: 401, Code: "error-unauthorized-admin-key-not-set"}
-
-// ErrMissingXpriv is when xpriv is missing
-var ErrMissingXpriv = models.SPVError{Message: "xpriv is missing", StatusCode: 401, Code: "error-unauthorized-xpriv-missing"}
-
-// ErrInvalidXpriv is when xpriv is invalid
-var ErrInvalidXpriv = models.SPVError{Message: "xpriv is invalid", StatusCode: 401, Code: "error-unauthorized-xpriv-invalid"}
-
-// ErrInvalidXpub is when xpub is invalid
-var ErrInvalidXpub = models.SPVError{Message: "xpub is invalid", StatusCode: 401, Code: "error-unauthorized-xpub-invalid"}
-
-// ErrInvalidAccessKey is when access key is invalid
-var ErrInvalidAccessKey = models.SPVError{Message: "access key is invalid", StatusCode: 401, Code: "error-unauthorized-access-key-invalid"}
-
-// ErrInvalidAdminKey is when admin key is invalid
-var ErrInvalidAdminKey = models.SPVError{Message: "admin key is invalid", StatusCode: 401, Code: "error-unauthorized-admin-key-invalid"}
-
-// ErrInvalidServerURL is when server url is invalid
-var ErrInvalidServerURL = models.SPVError{Message: "server url is invalid", StatusCode: 401, Code: "error-unauthorized-server-url-invalid"}
-
-// ErrCreateClient is when client creation fails
-var ErrCreateClient = models.SPVError{Message: "failed to create client", StatusCode: 500, Code: "error-create-client-failed"}
-
-// ErrMissingKey is when neither xPriv nor adminXPriv is provided
-var ErrMissingKey = models.SPVError{Message: "neither xPriv nor adminXPriv is provided", StatusCode: 404, Code: "error-shared-config-key-missing"}
-
-// ErrMissingAccessKey is when access key is missing
-var ErrMissingAccessKey = models.SPVError{Message: "access key is missing", StatusCode: 401, Code: "error-unauthorized-access-key-missing"}
-
-// ErrCouldNotFindDraftTransaction is when draft transaction is not found
-var ErrCouldNotFindDraftTransaction = models.SPVError{Message: "could not find draft transaction", StatusCode: 404, Code: "error-draft-transaction-not-found"}
-
-// ErrTotpInvalid is when totp is invalid
-var ErrTotpInvalid = models.SPVError{Message: "totp is invalid", StatusCode: 400, Code: "error-totp-invalid"}
-
-// ErrContactPubKeyInvalid is when contact's PubKey is invalid
-var ErrContactPubKeyInvalid = models.SPVError{Message: "contact's PubKey is invalid", StatusCode: 400, Code: "error-contact-pubkey-invalid"}
-
-// ErrStaleLastEvaluatedKey is when the last evaluated key returned from sync merkleroots is the same as it was in a previous iteration
-// indicating sync issue or a potential loop
-var ErrStaleLastEvaluatedKey = models.SPVError{Message: "The last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.", StatusCode: 500, Code: "error-stale-last-evaluated-key"}
-
-// ErrStaleLastEvaluatedKey is when the last evaluated key returned from sync merkleroots is the same as it was in a previous iteration
-// indicating sync issue or a potential loop
-var ErrSyncMerkleRootsTimeout = models.SPVError{Message: "SyncMerkleRoots operation timed out", StatusCode: 500, Code: "error-sync-merkleroots-timeout"}
-
-// WrapError wraps an error into SPVError
-func WrapError(err error) error {
- if err == nil {
- return nil
- }
-
- return models.SPVError{
- StatusCode: http.StatusInternalServerError,
- Message: err.Error(),
- Code: models.UnknownErrorCode,
- }
-}
-
-// WrapResponseError wraps a http response into SPVError
-func WrapResponseError(res *http.Response) error {
- if res == nil {
- return nil
- }
-
- var resError models.ResponseError
-
- err := json.NewDecoder(res.Body).Decode(&resError)
- if err != nil {
- return WrapError(err)
- }
-
- return models.SPVError{
- StatusCode: res.StatusCode,
- Code: resError.Code,
- Message: resError.Message,
- }
-}
-
-func CreateErrorResponse(code string, message string) error {
- return models.SPVError{
- StatusCode: http.StatusInternalServerError,
- Code: code,
- Message: message,
- }
-}
diff --git a/errors/errors.go b/errors/errors.go
new file mode 100644
index 00000000..1c8faa3f
--- /dev/null
+++ b/errors/errors.go
@@ -0,0 +1,84 @@
+package errors
+
+import (
+ "errors"
+)
+
+var (
+ // ErrQueryParserFailed is returned when the query parser encounters an error
+ // during initialization, such as when the provided query is nil or invalid.
+ ErrQueryParserFailed = errors.New("query parser: failed to initialize input query. Query can't be nil")
+
+ // ErrMissingXpriv is returned when the xpriv is missing.
+ ErrMissingXpriv = errors.New("xpriv is missing")
+
+ // ErrContactPubKeyInvalid is returned when the contact's PubKey is invalid.
+ ErrContactPubKeyInvalid = errors.New("contact's PubKey is invalid")
+
+ // ErrMetadataFilterMaxDepthExceeded is returned when the maximum depth of nesting in metadata map is exceeded.
+ ErrMetadataFilterMaxDepthExceeded = errors.New("maximum depth of nesting in metadata map exceeded")
+
+ // ErrMetadataWrongTypeInArray is returned when the wrong type is in the array.
+ ErrMetadataWrongTypeInArray = errors.New("wrong type in array")
+
+ // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API
+ // does not match the expected format or structure.
+ ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API")
+
+ // ErrSyncMerkleRootsTimeout is returned when the SyncMerkleRoots operation times out.
+ ErrSyncMerkleRootsTimeout = errors.New("SyncMerkleRoots operation timed out")
+
+ // ErrStaleLastEvaluatedKey is returned when the last evaluated key has not changed between requests,
+ ErrStaleLastEvaluatedKey = errors.New("the last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.")
+
+ // ErrFailedToFetchMerkleRootsFromAPI is returned when the API fails to fetch merkle roots.
+ ErrFailedToFetchMerkleRootsFromAPI = errors.New("failed to fetch merkle roots from API")
+
+ // ErrFailedToParseHex is returned when NewTransactionFromHex fails to create a transaction from given hex
+ ErrFailedToParseHex = errors.New("failed to parse hex")
+
+ // ErrCreateLockingScript is returned when TransactionSignedHex fails to create locking script
+ ErrCreateLockingScript = errors.New("failed to create locking script from hex for destination")
+
+ // ErrGetDerivedKeyForDestination is when TransactionSignedHex fails to get derived key for destination
+ ErrGetDerivedKeyForDestination = errors.New("failed to get derived key for destination")
+
+ // ErrCreateUnlockingScript is returned when TransactionSignedHex fails to create unlocking script
+ ErrCreateUnlockingScript = errors.New("failed to create unlocking script")
+
+ // ErrAddInputsToTransaction is returned when TransactionSignedHex fails to add inputs to transaction
+ ErrAddInputsToTransaction = errors.New("failed to add inputs to transaction")
+
+ // ErrSignTransaction is when TransactionSignedHex fails to sign the transaction
+ ErrSignTransaction = errors.New("failed to sign transaction")
+
+ // ErrEmptyXprivKey is returned when the xpriv string is empty.
+ ErrEmptyXprivKey = errors.New("key string cannot be empty")
+
+ // ErrEmptyAccessKey is returned when the access key string is empty.
+ ErrEmptyAccessKey = errors.New("key hex string cannot be empty")
+
+ // ErrEmptyPubKey is returned when the key string is empty.
+ ErrEmptyPubKey = errors.New("key string cannot be empty")
+
+ // ErrConfigValidationMissingAddress is returned when the configuration is invalid.
+ ErrConfigValidationMissingAddress = errors.New("configuration validation error: address required")
+
+ // ErrConfigValidationInvalidAddress is returned when the address is invalid.
+ ErrConfigValidationInvalidAddress = errors.New("configuration validation error: invalid address")
+
+ // ErrConfigValidationInvalidTimeout is returned when the timeout is invalid.
+ ErrConfigValidationInvalidTimeout = errors.New("configuration validation error: invalid timeout must be greater than zero")
+
+ // ErrConfigValidationInvalidTransport is returned when the transport is invalid.
+ ErrConfigValidationInvalidTransport = errors.New("configuration validation error: invalid transport")
+
+ // ErrMaxUint32LimitExceeded is returned when the max uint32 value is exceeded.
+ ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded")
+
+ // ErrNegativeValueNotAllowed is returned when a negative value is passed.
+ ErrNegativeValueNotAllowed = errors.New("negative value is not allowed")
+
+ // ErrHexHashPartIntParse is returned when the hex hash part fails to parse to int64.
+ ErrHexHashPartIntParse = errors.New("parse hex hash part to int64 failed")
+)
diff --git a/examples/README.md b/examples/README.md
index 73b27adb..41f52656 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,48 +1,188 @@
-# Quick Guide how to run examples
+# Quick Guide
-In this directory you can find examples of how to use the `spv-wallet-go-client` package.
+In this directory you can find bunch of examples describing how to use
+the wallet client package during interaction wit the SPV Wallet API.
+
+1. [Before you run](#before-you-run)
+1. [Authorization](#authorization)
+1. [Getting Started with the Project](#getting-started-with-the-project)
## Before you run
### Pre-requisites
-- You have access to the `spv-wallet` non-custodial wallet (running locally or remotely).
-- You have installed this package on your machine (`go install` on this project's root directory).
+- You have access to the `spv-wallet` non-custodial wallet (running locally or remotely).
+- [Taskfile](https://taskfile.dev/installation/) is installed on your environment.
+- SPV Wallet go client instance is properly created and configured.
+
+> [!TIP]
+> To verify Taskfile installation run: `task` command in the terminal.
+
+```
+task: [default] task --list
+task: Available tasks for this project:
+* access_key: Fetch Access key as User.
+* admin_add_user: Add user as Admin.
+* admin_remove_paymail: Remove paymail as Admin.
+* create_transaction: Create transaction as User.
+* default: Display all available tasks.
+* generate_keys: Generate keys for SPV Wallet API access.
+* generate_totp: Generate totp.
+* get_balance: Get balance as User.
+* get_shared_config: Get shared config as User.
+* list_access_keys: Fetch first page of access keys as User.
+* list_transactions: Fetch first page of transactions as User.
+* send_op_return: Create draft transaction, finalize transaction and record transaction as User.
+* sync_merkleroots: Sync Merkle roots as User.
+* update_user_xpub_metadata: Update xPub metadata as User.
+* xpriv_from_mnemonic: Extract xPriv from mnemonic.
+* xpub_from_xpriv: Extract xPub from xPriv.
+```
+
+## Authorization
+
+> [!CAUTION]
+> Don't use the keys which are already added to another wallet.
+
+> [!IMPORTANT]
+> Additionally, to make it work properly, you should adjust the `Paymail` to align with your `domains` configuration in the `spv-wallet` instance.
+
+> [!IMPORTANT]
+> `Paymail` is defined in example_keys.go file.
+
+Before interacting with the SPV Wallet API, you must complete the authorization process.
+
+To begin, generate a pair of keys using the `task generate_keys` command, which is included in the dedicated Taskfile.
+
+**Example output:**
+
+```
+==================================================================
+XPriv: xprv1d77e47e-452c-453f-bc4c-a42748f8145f
+XPub: xpubd82c277b-0a7e-482f-8ad8-e92958d15acb
+Mnemonic: mnemonic
+==================================================================
+```
+
+##
+
+> [!TIP]
+> Previously generated keys can be used as function parameters.
+
+To verify the connection and authorization, you can either run one of the available code snippets from the examples directory or use the following example. Please note that this is a testable code snippet and should be customized to fit your specific setup.
+
+**Code snippet:**
+
+```
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "strings"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+)
+
+func main() {
+ xPriv := "xprv1d77e47e-452c-453f-bc4c-a42748f8145f"
+ cfg := wallet.NewDefaultConfig("http://localhost:3003")
+ userAPI, err := wallet.NewUserAPIWithXPriv(cfg, xPriv)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ xPub, err := userAPI.XPub(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+ Print("XPub", xPub)
+}
+
+func Print(s string, a any) {
+ fmt.Println(strings.Repeat("~", 100))
+ fmt.Println(s)
+ fmt.Println(strings.Repeat("~", 100))
+ res, err := json.MarshalIndent(a, "", " ")
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(string(res))
+}
+```
+
+> [!TIP]
+> The same principle applies when creating an AdminAPI client instance using one of the available constructors.
+
+**Example output:**
+
+```
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+XPub
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+{
+ "createdAt": "2024-10-07T13:39:07.886862Z",
+ "updatedAt": "2024-11-20T11:05:22.235832Z",
+ "deletedAt": null,
+ "metadata": {
+ "metadata": {
+ "key": "value"
+ }
+ },
+ "id": "c50e4656-75e4-482e-a52d-2b4319919a26",
+ "currentBalance": 100,
+ "nextInternalNum": 20,
+ "nextExternalNum": 2
+}
+```
+
+## Getting Started with the Project
+
+To help you fully utilize this project, we've outlined a series of steps and important tips to guide you through the process.
+
+## Preliminary Setup
+
+> [!TIP]
+> For the best experience, we recommend transferring some funds to your Paymail. This allows the examples to demonstrate key functionality, such as creating transactions with an actual balance.
+
+You can transfer funds to your Paymail using any Bitcoin SV wallet application that supports Paymail, such as **HandCash** or similar applications.
+
+> [!IMPORTANT]
+> The following terms are defined in the `example_keys.go` file:
+> - **Paymail**
+> - **UserXPub**
-### Concerning the keys
+Ensure that this file is configured appropriately before running the examples.
-- The `ExampleAdminKey` defined in `example_keys.go` is the default one from [spv-wallet-web-backend repository](https://github.com/bitcoin-sv/spv-wallet-web-backend/blob/main/config/viper.go#L56)
- - If in your current `spv-wallet` instance you have a different `adminKey`, you should replace the one in `example_keys` with the one you have.
-- The `ExampleXPub` and `ExampleXPriv` are just placeholders, which won't work.
- - You should replace them by newly generated ones using `task generate_keys`,
- - ... or use your actual keys if you have them (don't use the keys which are already added to another wallet).
+## Recommended Order of Examples
-> Additionally, to make it work properly, you should adjust the `ExamplePaymail` to align with your `domains` configuration in the `spv-wallet` instance.
+1. **`generate_keys`**
+ Generates new keys. If you want to use these keys in subsequent examples, you can copy them to the `example_keys.go` file.
-## Proposed order of executing examples
+2. **`admin_add_user`**
+ Adds a new user to the wallet. Specifically, it registers the **UserXPub** and associates a **Paymail**.
-1. `generate_keys` - generates new keys (you can copy them to `example_keys` if you want to use them in next examples)
-2. `admin_add_user` - adds a new user (more precisely adds `ExampleXPub` and then `ExamplePaymail` to the wallet)
+3. **`get_balance`**
+ Retrieves the current balance for the user. If you've transferred funds to your Paymail, the balance will be displayed here.
-> To fully experience the next steps, it would be beneficial to transfer some funds to your `ExamplePaymail`. This ensures the examples run smoothly by demonstrating the creation of a transaction with an actual balance. You can transfer funds to your `ExamplePaymail` using a Bitcoin SV wallet application such as HandCash or any other that supports Paymail.
+4. **`create_transaction`**
+ Creates a transaction. You can customize the outputs to suit your specific requirements.
-3. `get_balance` - checks the balance - if you've transferred funds to your `ExamplePaymail`, you should see them here
-4. `create_transaction` - creates a transaction (you can adjust the `outputs` to your needs)
-5. `list_transactions` - lists all transactions and with example filtering
-6. `send_op_return` - sends an OP_RETURN transaction
-7. `admin_remove_user` - removes the user
+5. **`list_transactions`**
+ Lists all transactions. This includes examples of filtering options.
-In addition to the above, there are additional examples showing how to use the client from a developer perspective:
+6. **`send_op_return`**
+ Sends an OP_RETURN transaction, allowing you to attach data to the blockchain.
-- `handle_exceptions` - presents how to "catch" exceptions which the client can throw
+7. **`admin_remove_paymail`**
+ Removes the user by deleting their Paymail from the wallet.
-## Util examples
-1. `xpriv_from_mnemonic` - allows you to generate/extract an xPriv key from a mnemonic phrase. To you use it you just need to replace the `mnemonic` variable with your own mnemonic phrase.
-2. `xpub_from_xpriv` - allows you to generate an xPub key from an xPriv key. To you use it you just need to replace the `xPriv` variable with your own xPriv key.
-3. `generate_totp` - allows you to generate and check validity of a TOTP code for client xPriv and a contact's PKI
+## Next Steps
-## How to run an example
+Follow the steps in the suggested order to gain a comprehensive understanding of the project's functionality. If you encounter any issues or have questions, refer to the documentation or reach out for support.
The examples are written in Go and can be run by:
@@ -51,4 +191,5 @@ cd examples
task name_of_the_example
```
-> See the `examples/Taskfile.yml` for the list of available examples and scripts
+> [!TIP]
+> To verify Taskfile installation run: `task` command in the terminal.
diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml
index 88689de6..86071581 100644
--- a/examples/Taskfile.yml
+++ b/examples/Taskfile.yml
@@ -1,78 +1,127 @@
----
version: "3"
tasks:
+ default:
+ cmds:
+ - task --list
+ desc: "Display all available tasks."
+
+ access_key:
+ desc: "Fetch Access key as User."
+ silent: true
+ cmds:
+ - echo "=================================================================="
+ - go run ./access_key/access_key.go
+ - echo "=================================================================="
+
admin_add_user:
- desc: "running admin_add_user..."
+ desc: "Add user as Admin."
+ silent: true
cmds:
- - echo "running admin_add_user..."
+ - echo "=================================================================="
- go run ./admin_add_user/admin_add_user.go
+ - echo "=================================================================="
- admin_remove_user:
- desc: "running admin_remove_user..."
+ admin_remove_paymail:
+ desc: "Remove paymail as Admin."
+ silent: true
cmds:
- - echo "running admin_remove_user..."
- - go run ./admin_remove_user/admin_remove_user.go
+ - echo "=================================================================="
+ - go run ./admin_remove_paymail/admin_remove_paymail.go
+ - echo "=================================================================="
create_transaction:
- desc: "running create_transaction..."
+ desc: "Create transaction as User."
+ silent: true
cmds:
- - echo "running create_transaction..."
+ - echo "=================================================================="
- go run ./create_transaction/create_transaction.go
+ - echo "=================================================================="
generate_keys:
- desc: "running generate_keys..."
+ desc: "Generate keys for SPV Wallet API access."
+ silent: true
+ cmds:
+ - echo "=================================================================="
+ - go run ../walletkeys/cmd/main.go
+ - echo "=================================================================="
+
+ generate_totp:
+ desc: "Generate totp."
+ silent: true
cmds:
- - echo "running generate_keys..."
- - go run ./generate_keys/generate_keys.go
+ - echo "=================================================================="
+ - go run ./generate_totp/generate_totp.go
+ - echo "=================================================================="
get_balance:
- desc: "running get_balance..."
+ desc: "Get balance as User."
+ silent: true
cmds:
- - echo "running get_balance..."
+ - echo "=================================================================="
- go run ./get_balance/get_balance.go
+ - echo "=================================================================="
+
+ get_shared_config:
+ desc: "Get shared config as User."
+ silent: true
+ cmds:
+ - echo "=================================================================="
+ - go run ./get_shared_config/get_shared_config.go
+ - echo "=================================================================="
- handle_exceptions:
- desc: "running handle_exceptions..."
+ list_access_keys:
+ desc: "Fetch first page of access keys as User."
+ silent: true
cmds:
- - echo "running handle_exceptions..."
- - go run ./handle_exceptions/handle_exceptions.go
+ - echo "=================================================================="
+ - go run ./list_access_keys/list_access_keys.go
+ - echo "=================================================================="
list_transactions:
- desc: "running list_transactions..."
+ desc: "Fetch first page of transactions as User."
+ silent: true
cmds:
- - echo "running list_transactions..."
+ - echo "=================================================================="
- go run ./list_transactions/list_transactions.go
+ - echo "=================================================================="
send_op_return:
- desc: "running send_op_return..."
+ desc: "Create draft transaction, finalize transaction and record transaction as User."
+ silent: true
cmds:
- - echo "running send_op_return..."
+ - echo "=================================================================="
- go run ./send_op_return/send_op_return.go
+ - echo "=================================================================="
+
+ sync_merkleroots:
+ desc: "Sync Merkle roots as User."
+ silent: true
+ cmds:
+ - echo "=================================================================="
+ - go run ./sync_merkleroots/sync_merkleroots.go
+ - echo "=================================================================="
+
+ update_user_xpub_metadata:
+ desc: "Update xPub metadata as User."
+ silent: true
+ cmds:
+ - echo "=================================================================="
+ - go run ./update_user_xpub_metadata/update_user_xpub_metadata.go
+ - echo "=================================================================="
xpriv_from_mnemonic:
- desc: "running xpriv_from_mnemonic..."
+ desc: "Extract xPriv from mnemonic."
+ silent: true
cmds:
- - echo "running xpriv_from_mnemonic..."
+ - echo "=================================================================="
- go run ./xpriv_from_mnemonic/xpriv_from_mnemonic.go
+ - echo "=================================================================="
xpub_from_xpriv:
- desc: "running xpub_from_xpriv..."
+ desc: "Extract xPub from xPriv."
+ silent: true
cmds:
- - echo "running xpub_from_xpriv..."
+ - echo "=================================================================="
- go run ./xpub_from_xpriv/xpub_from_xpriv.go
- generate_totp:
- desc: "running generate_totp..."
- cmds:
- - echo "running generate_totp..."
- - go run ./generate_totp/generate_totp.go
- webhooks:
- desc: "running webhooks..."
- cmds:
- - echo "running webhooks..."
- - go run ./webhooks/webhooks.go || true
- sync_merkleroots:
- desc: "running sync_merkleroots.."
- cmds:
- - echo "running sync_merkleroots..."
- - go run ./sync_merkleroots/sync_merkleroots.go
+ - echo "=================================================================="
diff --git a/examples/access_key/access_key.go b/examples/access_key/access_key.go
new file mode 100644
index 00000000..f0c9f8a0
--- /dev/null
+++ b/examples/access_key/access_key.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+)
+
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
+ }
+
+ ctx := context.Background()
+ generated, err := usersAPI.GenerateAccessKey(ctx, &commands.GenerateAccessKey{
+ Metadata: queryparams.Metadata{"key": "value"},
+ })
+ if err != nil {
+ log.Fatalf("Failed to generate access key: %v", err)
+ }
+ exampleutil.PrettyPrint("Generated access key", generated)
+
+ fetched, err := usersAPI.AccessKey(ctx, generated.ID)
+ if err != nil {
+ log.Fatalf("Failed to fetch access key: %v", err)
+ }
+ exampleutil.PrettyPrint("Fetched access key", fetched)
+
+ err = usersAPI.RevokeAccessKey(ctx, generated.ID)
+ if err != nil {
+ log.Fatalf("Failed to revoke access key: %v", err)
+ }
+ fmt.Printf("Revoke access key: %s\n", generated.ID)
+}
diff --git a/examples/admin_add_user/admin_add_user.go b/examples/admin_add_user/admin_add_user.go
index d0645136..34940da0 100644
--- a/examples/admin_add_user/admin_add_user.go
+++ b/examples/admin_add_user/admin_add_user.go
@@ -1,43 +1,39 @@
-/*
-Package main - admin_add_user example
-*/
package main
import (
"context"
- "fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
)
func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfAdminKeyExists()
-
- server := "http://localhost:3003/v1"
-
- adminClient, err := walletclient.NewWithAdminKey(server, examples.ExampleAdminKey)
+ adminAPI, err := wallet.NewAdminAPIWithXPub(exampleutil.NewDefaultConfig(), examples.AdminXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize admin API with XPriv: %v", err)
}
- ctx := context.Background()
-
- metadata := map[string]any{"some_metadata": "example"}
- err = adminClient.AdminNewXpub(ctx, examples.ExampleXPub, metadata)
+ ctx := context.Background()
+ xPub, err := adminAPI.CreateXPub(ctx, &commands.CreateUserXpub{
+ XPub: examples.UserXPub,
+ Metadata: queryparams.Metadata{"key": "value"},
+ })
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to create xPub: %v", err)
}
+ exampleutil.PrettyPrint("Created XPub", xPub)
- createPaymailRes, err := adminClient.AdminCreatePaymail(ctx, examples.ExampleXPub, examples.ExamplePaymail, "Some public name", "")
+ paymail, err := adminAPI.CreatePaymail(ctx, &commands.CreatePaymail{
+ Metadata: queryparams.Metadata{"key": "value"},
+ Key: examples.UserXPub,
+ Address: examples.Paymail,
+ })
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to create paymail: %v", err)
}
- fmt.Println("AdminCreatePaymail response: ", createPaymailRes)
+ exampleutil.PrettyPrint("Created paymail", paymail)
}
diff --git a/examples/admin_remove_paymail/admin_remove_paymail.go b/examples/admin_remove_paymail/admin_remove_paymail.go
new file mode 100644
index 00000000..982250ba
--- /dev/null
+++ b/examples/admin_remove_paymail/admin_remove_paymail.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+)
+
+func main() {
+ adminAPI, err := wallet.NewAdminAPIWithXPub(exampleutil.NewDefaultConfig(), examples.AdminXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize admin API with XPriv: %v", err)
+ }
+
+ ctx := context.Background()
+ err = adminAPI.DeletePaymail(ctx, examples.Paymail)
+ if err != nil {
+ log.Fatalf("Failed to delete paymail: %v", err)
+ }
+
+ fmt.Printf("Paymail deleted: %s\n", examples.Paymail)
+}
diff --git a/examples/admin_remove_user/admin_remove_user.go b/examples/admin_remove_user/admin_remove_user.go
deleted file mode 100644
index 95b7949c..00000000
--- a/examples/admin_remove_user/admin_remove_user.go
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
-Package main - admin_remove_user example
-*/
-package main
-
-import (
- "context"
- "os"
-
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
- "github.com/bitcoin-sv/spv-wallet-go-client/examples"
-)
-
-func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfAdminKeyExists()
-
- const server = "http://localhost:3003/v1"
-
- adminClient, err := walletclient.NewWithAdminKey(server, examples.ExampleAdminKey)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- ctx := context.Background()
-
- err = adminClient.AdminDeletePaymail(ctx, examples.ExamplePaymail)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-}
diff --git a/examples/create_transaction/create_transaction.go b/examples/create_transaction/create_transaction.go
index d3d6a64d..baf779c1 100644
--- a/examples/create_transaction/create_transaction.go
+++ b/examples/create_transaction/create_transaction.go
@@ -1,46 +1,41 @@
-/*
-Package main - create_transaction example
-*/
package main
import (
"context"
- "fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
)
func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfXPrivExists()
-
- const server = "http://localhost:3003/v1"
-
- client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv)
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- ctx := context.Background()
- recipient := walletclient.Recipients{To: "alice@example.com", Satoshis: 1}
- recipients := []*walletclient.Recipients{&recipient}
- metadata := map[string]any{"some_metadata": "example"}
-
- newTransaction, err := client.SendToRecipients(ctx, recipients, metadata)
+ ctx := context.Background()
+ created, err := usersAPI.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{
+ {
+ Satoshis: 1,
+ To: "alice@example.com",
+ },
+ },
+ Metadata: queryparams.Metadata{"key": "value"},
+ })
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to create transaction: %v", err)
}
- fmt.Println("SendToRecipients response: ", newTransaction)
+ exampleutil.PrettyPrint("Created transaction", created)
- tx, err := client.GetTransaction(ctx, newTransaction.ID)
+ fetch, err := usersAPI.Transaction(ctx, created.ID)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to fetch transaction: %v", err)
}
- fmt.Println("GetTransaction response: ", tx)
+
+ exampleutil.PrettyPrint("Fetched transaction", fetch)
}
diff --git a/examples/errors.go b/examples/errors.go
deleted file mode 100644
index 932a7527..00000000
--- a/examples/errors.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package examples
-
-import (
- "errors"
- "fmt"
-
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-// GetFullErrorMessage prints detailed info about the error
-func GetFullErrorMessage(err error) {
- var errMsg string
-
- var spvError models.SPVError
- if errors.As(err, &spvError) {
- errMsg = fmt.Sprintf("Error, Message: %s, Code: %s, HTTP status code: %d", spvError.GetMessage(), spvError.GetCode(), spvError.GetStatusCode())
- } else {
- errMsg = fmt.Sprintf("Error, Message: %s, Code: %s, HTTP status code: %d", err.Error(), models.UnknownErrorCode, 500)
- }
- fmt.Println(errMsg)
-}
diff --git a/examples/example_keys.go b/examples/example_keys.go
index 8c527470..d7dcfdf1 100644
--- a/examples/example_keys.go
+++ b/examples/example_keys.go
@@ -1,19 +1,14 @@
-/*
-Package examples - key constants to be used in the examples and utility function for generating keys
-*/
package examples
const (
- // ExampleAdminKey - example admin key
- ExampleAdminKey string = "xprv9s21ZrQH143K3CbJXirfrtpLvhT3Vgusdo8coBritQ3rcS7Jy7sxWhatuxG5h2y1Cqj8FKmPp69536gmjYRpfga2MJdsGyBsnB12E19CESK"
-
- // you can generate new keys using `task generate_keys`
-
- // ExampleXPriv - example private key
- ExampleXPriv string = ""
- // ExampleXPub - example public key
- ExampleXPub string = ""
+ Alias string = "test"
+ Domain string = "example.com"
+ Paymail string = Alias + "@" + Domain
+)
- // ExamplePaymail - example Paymail address
- ExamplePaymail string = ""
+const (
+ AdminXPriv string = ""
+ UserXPriv string = ""
+ UserXPub string = ""
+ AccessKey string = ""
)
diff --git a/examples/exampleutil/exampleutil.go b/examples/exampleutil/exampleutil.go
new file mode 100644
index 00000000..a5ce3354
--- /dev/null
+++ b/examples/exampleutil/exampleutil.go
@@ -0,0 +1,33 @@
+package exampleutil
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+)
+
+// NewDefaultConfig returns a new instance of the default example configuration.
+func NewDefaultConfig() config.Config {
+ return config.New()
+}
+
+// PrettyPrint formats the provided JSON content with proper indentation
+// to improve readability. It also displays a title, framed by two lines
+// of `~` characters, for better visual presentation.
+func PrettyPrint(title string, JSON any) {
+ sep := strings.Repeat("~", 100)
+ fmt.Println(sep)
+ fmt.Println(title)
+ fmt.Println(sep)
+
+ res, err := json.MarshalIndent(JSON, "", " ")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println(string(res))
+ fmt.Println()
+}
diff --git a/examples/generate_keys/generate_keys.go b/examples/generate_keys/generate_keys.go
index 93af46c4..40895778 100644
--- a/examples/generate_keys/generate_keys.go
+++ b/examples/generate_keys/generate_keys.go
@@ -1,25 +1,18 @@
-/*
-Package main - generate_keys example
-*/
package main
import (
"fmt"
- "os"
+ "log"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
)
func main() {
- keys, err := xpriv.Generate()
+ keys, err := walletkeys.RandomKeys()
if err != nil {
- fmt.Println(err)
- os.Exit(1)
+ log.Fatalf("Failed to generate random keys: %v", err)
}
- exampleXPriv := keys.XPriv()
- exampleXPub := keys.XPub().String()
-
- fmt.Println("exampleXPriv: ", exampleXPriv)
- fmt.Println("exampleXPub: ", exampleXPub)
+ fmt.Printf("Generated xPub for user: %s\n", keys.XPub())
+ fmt.Printf("Generated xPriv for user: %s\n", keys.XPriv())
}
diff --git a/examples/generate_totp/generate_totp.go b/examples/generate_totp/generate_totp.go
index 7a06fb82..1a240b8e 100644
--- a/examples/generate_totp/generate_totp.go
+++ b/examples/generate_totp/generate_totp.go
@@ -1,48 +1,43 @@
-/*
-Package main - generate_totp example
-*/
package main
import (
"fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
"github.com/bitcoin-sv/spv-wallet/models"
)
func main() {
- defer examples.HandlePanic()
+ const aliceXPriv = examples.UserXPriv
- const server = "http://localhost:3003/v1"
- const aliceXPriv = "xprv9s21ZrQH143K4JFXqGhBzdrthyNFNuHPaMUwvuo8xvpHwWXprNK7T4JPj1w53S1gojQncyj8JhSh8qouYPZpbocsq934cH5G1t1DRBfgbod"
+ // pubKey - PKI can be obtained from the contact's paymail capability
const bobPKI = "03a48e13dc598dce5fda9b14ea13f32d5dbc4e8d8a34447dda84f9f4c457d57fe7"
const digits = 4
- const period = 1200 // 20 minutes
+ const period = 1200
- client, err := walletclient.NewWithXPriv(server, aliceXPriv)
+ alice, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), aliceXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- mockContact := &models.Contact{
+ bob := &models.Contact{
PubKey: bobPKI,
Paymail: "test@paymail.com",
}
-
- totpCode, err := client.GenerateTotpForContact(mockContact, period, digits)
+ code, err := alice.GenerateTotpForContact(bob, period, digits)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to generate totp for contact: %v", err)
}
- fmt.Println("TOTP code from Alice to Bob: ", totpCode)
- valid, err := client.ValidateTotpForContact(mockContact, totpCode, mockContact.Paymail, period, digits)
+ fmt.Println("TOTP code from Alice to Bob: ", code)
+
+ err = alice.ValidateTotpForContact(bob, code, bob.Paymail, period, digits)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to validate totp for contact: %v", err)
}
- fmt.Println("Is TOTP code valid: ", valid)
+
+ fmt.Println("TOTP code from Alice to Bob is valid")
}
diff --git a/examples/get_balance/get_balance.go b/examples/get_balance/get_balance.go
index 2d0ee6ad..49a18be6 100644
--- a/examples/get_balance/get_balance.go
+++ b/examples/get_balance/get_balance.go
@@ -1,35 +1,26 @@
-/*
-Package main - get_balance example
-*/
package main
import (
"context"
"fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
)
func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfXPrivExists()
-
- const server = "http://localhost:3003/v1"
-
- client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv)
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- ctx := context.Background()
- xpubInfo, err := client.GetXPub(ctx)
+ ctx := context.Background()
+ xPub, err := usersAPI.XPub(ctx)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to fetch xPub: %v", err)
}
- fmt.Println("Current balance: ", xpubInfo.CurrentBalance)
+
+ fmt.Printf("Current balance: %v\n", xPub.CurrentBalance)
}
diff --git a/examples/get_shared_config/get_shared_config.go b/examples/get_shared_config/get_shared_config.go
new file mode 100644
index 00000000..6a2daa28
--- /dev/null
+++ b/examples/get_shared_config/get_shared_config.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+ "context"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+)
+
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
+ }
+
+ ctx := context.Background()
+ cfg, err := usersAPI.SharedConfig(ctx)
+ if err != nil {
+ log.Fatalf("Failed to shared config: %v", err)
+ }
+
+ exampleutil.PrettyPrint("Fetched shared config", cfg)
+}
diff --git a/examples/go.mod b/examples/go.mod
deleted file mode 100644
index 0cc32175..00000000
--- a/examples/go.mod
+++ /dev/null
@@ -1,18 +0,0 @@
-module github.com/bitcoin-sv/spv-wallet-go-client/examples
-
-go 1.22.5
-
-replace github.com/bitcoin-sv/spv-wallet-go-client => ../
-
-require (
- github.com/bitcoin-sv/spv-wallet-go-client v0.0.0-00010101000000-000000000000
- github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39
-)
-
-require (
- github.com/bitcoin-sv/go-sdk v1.1.16 // indirect
- github.com/boombuler/barcode v1.0.2 // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/pquerna/otp v1.4.0 // indirect
- golang.org/x/crypto v0.31.0 // indirect
-)
diff --git a/examples/go.sum b/examples/go.sum
deleted file mode 100644
index 759c6398..00000000
--- a/examples/go.sum
+++ /dev/null
@@ -1,24 +0,0 @@
-github.com/bitcoin-sv/go-sdk v1.1.16 h1:n2X0RiENFGD/1fQ/1y6osbostRB7I/xq9I7tcIKcCPY=
-github.com/bitcoin-sv/go-sdk v1.1.16/go.mod h1:3CsNdEDBwB+SIv6UBcJPC9bTvPqxQvg3GULt7wsuL58=
-github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39 h1:qo74o72mcdj7AYJoCq7RG3enHJiqtbkFEY9uXvEEG2M=
-github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39/go.mod h1:UdY5AGsO9IomUEYSPilcSY+3BTQRJswdfZNveLt6LZQ=
-github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
-github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
-github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/handle_exceptions/handle_exceptions.go b/examples/handle_exceptions/handle_exceptions.go
deleted file mode 100644
index ed55de42..00000000
--- a/examples/handle_exceptions/handle_exceptions.go
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
-Package main - handle_exceptions example
-*/
-package main
-
-import (
- "context"
- "fmt"
- "os"
-
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
- "github.com/bitcoin-sv/spv-wallet-go-client/examples"
-)
-
-func main() {
- defer examples.HandlePanic()
-
- fmt.Println("Handle exceptions example")
-
- examples.CheckIfXPubExists()
-
- fmt.Println("XPub exists")
-
- const server = "http://localhost:3003/v1"
-
- client, err := walletclient.NewWithXPub(server, examples.ExampleAdminKey)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- ctx := context.Background()
-
- fmt.Println("Client created")
-
- status, err := client.AdminGetStatus(ctx)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-
- fmt.Println("Status: ", status)
-}
diff --git a/examples/list_access_keys/list_access_keys.go b/examples/list_access_keys/list_access_keys.go
new file mode 100644
index 00000000..121fa05f
--- /dev/null
+++ b/examples/list_access_keys/list_access_keys.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "context"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+)
+
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
+ }
+
+ page, err := usersAPI.AccessKeys(context.Background())
+ if err != nil {
+ log.Fatalf("Failed to fetch access keys: %v", err)
+ }
+ exampleutil.PrettyPrint("Fetched access keys", page.Content)
+}
diff --git a/examples/list_contacts/list_contacts.go b/examples/list_contacts/list_contacts.go
new file mode 100644
index 00000000..be30c097
--- /dev/null
+++ b/examples/list_contacts/list_contacts.go
@@ -0,0 +1,28 @@
+package listcontacts
+
+import (
+ "context"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+)
+
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
+ }
+
+ page, err := usersAPI.Contacts(context.Background(), queries.QueryWithPageFilter[filter.ContactFilter](filter.Page{
+ Size: 1,
+ Sort: "asc",
+ }))
+ if err != nil {
+ log.Fatalf("Failed to fetch contacts: %v", err)
+ }
+ exampleutil.PrettyPrint("Fetched contacts", page.Content)
+}
diff --git a/examples/list_transactions/list_transactions.go b/examples/list_transactions/list_transactions.go
index 670c2f2d..6e27dc45 100644
--- a/examples/list_transactions/list_transactions.go
+++ b/examples/list_transactions/list_transactions.go
@@ -1,52 +1,28 @@
-/*
-Package main - list_transactions example
-*/
package main
import (
"context"
- "fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
"github.com/bitcoin-sv/spv-wallet/models/filter"
)
func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfXPrivExists()
-
- const server = "http://localhost:3003/v1"
-
- client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv)
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- ctx := context.Background()
-
- metadata := map[string]any{}
-
- conditions := filter.TransactionFilter{}
- queryParams := filter.QueryParams{}
-
- txs, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- fmt.Println("GetTransactions response: ", txs)
-
- targetBlockHeight := uint64(839228)
- conditions = filter.TransactionFilter{BlockHeight: &targetBlockHeight}
- queryParams = filter.QueryParams{PageSize: 100, Page: 1}
- txsFiltered, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams)
+ page, err := usersAPI.Transactions(context.Background(), queries.QueryWithPageFilter[filter.TransactionFilter](filter.Page{
+ Size: 1,
+ Sort: "asc",
+ }))
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to fetch transactions: %v", err)
}
- fmt.Println("Filtered GetTransactions response: ", txsFiltered)
+ exampleutil.PrettyPrint("Fetched transactions", page.Content)
}
diff --git a/examples/send_op_return/send_op_return.go b/examples/send_op_return/send_op_return.go
index 04aae504..a1093a28 100644
--- a/examples/send_op_return/send_op_return.go
+++ b/examples/send_op_return/send_op_return.go
@@ -1,53 +1,59 @@
-/*
-Package main - send_op_return example
-*/
package main
import (
"context"
"fmt"
- "os"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
- "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
)
func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfXPrivExists()
-
- const server = "http://localhost:3003/v1"
-
- client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv)
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- ctx := context.Background()
-
- metadata := map[string]any{}
- opReturn := models.OpReturn{StringParts: []string{"hello", "world"}}
- transactionConfig := models.TransactionConfig{Outputs: []*models.TransactionOutput{{OpReturn: &opReturn}}}
+ ctx := context.Background()
+ draftTransaction, err := usersAPI.DraftTransaction(ctx, &commands.DraftTransaction{
+ Config: response.TransactionConfig{
+ Outputs: []*response.TransactionOutput{
+ {
+ OpReturn: &response.OpReturn{StringParts: []string{"hello", "world"}},
+ },
+ },
+ },
+ Metadata: queryparams.Metadata{},
+ })
+ if err != nil {
+ log.Fatalf("Failed to create draft transaction: %v", err)
+ }
+ exampleutil.PrettyPrint("Created DraftTransaction", draftTransaction)
- draftTransaction, err := client.DraftTransaction(ctx, &transactionConfig, metadata)
+ finalized, err := usersAPI.FinalizeTransaction(draftTransaction)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to finalize draft transaction: %v", err)
}
- fmt.Println("DraftTransaction response: ", draftTransaction)
+ fmt.Printf("Finalized draft transaction hex: %s\n", finalized)
- finalized, err := client.FinalizeTransaction(draftTransaction)
+ transaction, err := usersAPI.RecordTransaction(ctx, &commands.RecordTransaction{
+ Hex: finalized,
+ Metadata: queryparams.Metadata{},
+ ReferenceID: draftTransaction.ID,
+ })
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to record finalized transaction: %v", err)
}
- transaction, err := client.RecordTransaction(ctx, finalized, draftTransaction.ID, metadata)
+ exampleutil.PrettyPrint("Recorded transaction with OP_RETURN", transaction)
+
+ transactionG, err := usersAPI.Transaction(context.Background(), transaction.ID)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatal(err)
}
- fmt.Println("Transaction with OP_RETURN: ", transaction)
+ exampleutil.PrettyPrint("Fetched transaction", transactionG)
}
diff --git a/examples/sync_merkleroots/sync_merkleroots.go b/examples/sync_merkleroots/sync_merkleroots.go
index da9da731..c66eca5d 100644
--- a/examples/sync_merkleroots/sync_merkleroots.go
+++ b/examples/sync_merkleroots/sync_merkleroots.go
@@ -1,88 +1,45 @@
-/*
-Package main - sync_merkleroots example
-*/
package main
import (
"context"
- "fmt"
- "os"
- "time"
+ "log"
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
"github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
"github.com/bitcoin-sv/spv-wallet/models"
)
-// simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method
-type db struct {
- MerkleRoots []models.MerkleRoot
-}
-
-func (db *db) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error {
- fmt.Print("\nSaveMerkleRoots called\n")
- db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...)
- return nil
-}
-
-func (db *db) GetLastMerkleRoot() string {
- if len(db.MerkleRoots) == 0 {
- return ""
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
}
- return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot
-}
-// initalize the storage that exists on a client side
-var repository = &db{
- MerkleRoots: []models.MerkleRoot{
- {
- MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
- BlockHeight: 0,
- },
- {
- MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
- BlockHeight: 1,
- },
- {
- MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
- BlockHeight: 2,
- },
- },
-}
+ var db db
+ exampleutil.PrettyPrint("Merkle roots in db before sync", db.roots)
-func getLastFiveOrFewer(merkleroots []models.MerkleRoot) []models.MerkleRoot {
- startIndex := len(merkleroots) - 5
- if startIndex < 0 {
- startIndex = 0
+ ctx := context.Background()
+ err = usersAPI.SyncMerkleRoots(ctx, &db)
+ if err != nil {
+ log.Fatalf("Failed to sync merkle roots: %v", err)
}
- return merkleroots[startIndex:]
+ exampleutil.PrettyPrint("Merkle roots in db after sync", db.roots)
}
-func main() {
- defer examples.HandlePanic()
-
- server := "http://localhost:3003/api/v1"
-
- client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv)
- if err != nil {
- fmt.Println("Error: ", err)
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond)
- defer cancel()
-
- fmt.Printf("\n\n Initial State Length: \n %d\n\n", len(repository.MerkleRoots))
- fmt.Printf("\n\nInitial State Last 5 MerkleRoots (or fewer):\n%+v\n", getLastFiveOrFewer(repository.MerkleRoots))
+type db struct {
+ roots []models.MerkleRoot
+}
- err = client.SyncMerkleRoots(ctx, repository)
- if err != nil {
- fmt.Println("Error: ", err)
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+func (d *db) GetLastMerkleRoot() string {
+ if len(d.roots) == 0 {
+ return ""
}
+ return d.roots[len(d.roots)-1].MerkleRoot
+}
- fmt.Printf("\n\n After Sync State Length: \n %d\n\n", len(repository.MerkleRoots))
- fmt.Printf("\n\n After Sync State Last 5 MerkleRoots (or fewer):\n%+v\n", getLastFiveOrFewer(repository.MerkleRoots))
+func (d *db) SaveMerkleRoots(roots []models.MerkleRoot) error {
+ d.roots = append(d.roots, roots...)
+ return nil
}
diff --git a/examples/update_user_xpub_metadata/update_user_xpub_metadata.go b/examples/update_user_xpub_metadata/update_user_xpub_metadata.go
new file mode 100644
index 00000000..94f83ec1
--- /dev/null
+++ b/examples/update_user_xpub_metadata/update_user_xpub_metadata.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "context"
+ "log"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples"
+ "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+)
+
+func main() {
+ usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.UserXPriv)
+ if err != nil {
+ log.Fatalf("Failed to initialize user API with XPriv: %v", err)
+ }
+
+ ctx := context.Background()
+ xPub, err := usersAPI.XPub(ctx)
+ if err != nil {
+ log.Fatalf("Failed to fetch xPub: %v", err)
+ }
+ exampleutil.PrettyPrint("User xPub info before update", xPub)
+
+ xPub, err = usersAPI.UpdateXPubMetadata(ctx, &commands.UpdateXPubMetadata{
+ Metadata: queryparams.Metadata{"new_key": "new_value"},
+ })
+ if err != nil {
+ log.Fatalf("Failed to fetch xPub: %v", err)
+ }
+ exampleutil.PrettyPrint("User xPub info after update", xPub)
+}
diff --git a/examples/utils.go b/examples/utils.go
deleted file mode 100644
index 0fb323e8..00000000
--- a/examples/utils.go
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
-Package examples - Utility functions for this package
-*/
-package examples
-
-import (
- "fmt"
- "os"
-)
-
-func printMissingKeyError(key string) {
- fmt.Printf("Please provide a valid %s. ", key)
-}
-
-// HandlePanic - function used to handle a recovery after a panic - use with defer
-func HandlePanic() {
- r := recover()
-
- if r != nil {
- fmt.Println("Recovering: ", r)
- }
-}
-
-// CheckIfXPrivExists - checks if ExampleXPriv is not empty
-func CheckIfXPrivExists() {
- if ExampleXPriv == "" {
- printMissingKeyError("xPriv")
- os.Exit(1)
- }
-}
-
-// CheckIfXPubExists - checks if ExampleXPub is not empty
-func CheckIfXPubExists() {
- if ExampleXPub == "" {
- printMissingKeyError("xPub")
- os.Exit(1)
- }
-}
-
-// CheckIfAdminKeyExists - checks if ExampleAdminKey is not empty
-func CheckIfAdminKeyExists() {
- if ExampleAdminKey == "" {
- printMissingKeyError("adminKey")
- os.Exit(1)
- }
-}
diff --git a/examples/webhooks/webhooks.go b/examples/webhooks/webhooks.go
deleted file mode 100644
index 3dc1b640..00000000
--- a/examples/webhooks/webhooks.go
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
-Package main - send_op_return example
-*/
-package main
-
-import (
- "context"
- "fmt"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
- "github.com/bitcoin-sv/spv-wallet-go-client/examples"
- "github.com/bitcoin-sv/spv-wallet-go-client/notifications"
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-func main() {
- defer examples.HandlePanic()
-
- examples.CheckIfAdminKeyExists()
-
- client, err := walletclient.NewWithAdminKey("http://localhost:3003/v1", examples.ExampleAdminKey)
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- wh := notifications.NewWebhook(
- client,
- "http://localhost:5005/notification",
- notifications.WithToken("Authorization", "this-is-the-token"),
- notifications.WithProcessors(3),
- )
- err = wh.Subscribe(context.Background())
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-
- http.Handle("/notification", wh.HTTPHandler())
-
- // show all subscribed webhooks (including the current one)
- allWebhooks, err := client.AdminGetWebhooks(context.Background())
- if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
- fmt.Println("Subscribed webhooks list")
- for _, item := range allWebhooks {
- fmt.Printf("URL: %s, banned: %v\n", item.URL, item.Banned)
- }
-
- if err = notifications.RegisterHandler(wh, func(gpe *models.StringEvent) {
- time.Sleep(50 * time.Millisecond) // simulate processing time
- fmt.Printf("Processing event-string: %s\n", gpe.Value)
- }); err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-
- if err = notifications.RegisterHandler(wh, func(gpe *models.TransactionEvent) {
- time.Sleep(50 * time.Millisecond) // simulate processing time
- fmt.Printf("Processing event-transaction: XPubID: %s, TxID: %s, Status: %s\n", gpe.XPubID, gpe.TransactionID, gpe.Status)
- }); err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-
- server := http.Server{
- Addr: ":5005",
- Handler: nil,
- ReadHeaderTimeout: time.Second * 10,
- }
- go func() {
- _ = server.ListenAndServe()
- }()
-
- // wait for signal to shutdown
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- <-sigChan
-
- fmt.Printf("Unsubscribing...\n")
- if err = wh.Unsubscribe(context.Background()); err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-
- fmt.Printf("Shutting down...\n")
- if err = server.Shutdown(context.Background()); err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
- }
-}
diff --git a/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go b/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go
index 2332ea30..02d01faf 100644
--- a/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go
+++ b/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go
@@ -1,25 +1,20 @@
-/*
-Package main - xpriv_from_mnemonic example
-*/
package main
import (
"fmt"
- "github.com/bitcoin-sv/spv-wallet-go-client/examples"
- "os"
+ "log"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
)
func main() {
// This is an example mnemonic phrase - replace it with your own
const mnemonicPhrase = "nut same spike popular already mercy kit board rent light illegal local eight filter tube"
- keys, err := xpriv.FromMnemonic(mnemonicPhrase)
+ key, err := walletkeys.XPrivFromMnemonic(mnemonicPhrase)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to get xPriv from mnemonic: %v", err)
}
- fmt.Println("extracted xPriv: ", keys.XPriv())
+ fmt.Printf("Extracted xPriv: %s\n", key.String())
}
diff --git a/examples/xpub_from_xpriv/xpub_from_xpriv.go b/examples/xpub_from_xpriv/xpub_from_xpriv.go
index 83e077d1..8ca593a2 100644
--- a/examples/xpub_from_xpriv/xpub_from_xpriv.go
+++ b/examples/xpub_from_xpriv/xpub_from_xpriv.go
@@ -1,25 +1,19 @@
-/*
-Package main - xpub_from_xpriv example
-*/
package main
import (
"fmt"
- "github.com/bitcoin-sv/spv-wallet-go-client/examples"
- "os"
+ "log"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
)
func main() {
// This is an example xPriv key - replace it with your own
const xPriv = "xprv9s21ZrQH143K4VneY3UWCF1o5Kk2tmgGrGtMtsrThCTsHsszEZ6H1iP37ZTwuUBvMwudG68SRkcfTjeu8h3rkayfyqkjKAStFBkuNsBnAkS"
- keys, err := xpriv.FromString(xPriv)
+ xPub, err := walletkeys.XPubFromXPriv(xPriv)
if err != nil {
- examples.GetFullErrorMessage(err)
- os.Exit(1)
+ log.Fatalf("Failed to get xPriv from mnemonic: %v", err)
}
-
- fmt.Println("extracted xPub: ", keys.XPub().String())
+ fmt.Printf("Extracted xPub: %s\n", xPub)
}
diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go
deleted file mode 100644
index 12990b53..00000000
--- a/fixtures/fixtures.go
+++ /dev/null
@@ -1,217 +0,0 @@
-// Package fixtures contains fixtures for testing
-package fixtures
-
-import (
- "encoding/json"
-
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/bitcoin-sv/spv-wallet/models/common"
- responsemodels "github.com/bitcoin-sv/spv-wallet/models/response"
-)
-
-var (
- // RequestType http or https
- RequestType = "http"
- // ServerURL ex. https://localhost
- ServerURL = "https://example.com/"
- // XPubString public key
- XPubString = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J"
- // XPrivString private key
- XPrivString = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ"
- // AccessKeyString access key
- AccessKeyString = "7779d24ca6f8821f225042bf55e8f80aa41b08b879b72827f51e41e6523b9cd0"
- // PaymailAddress ex. "address@paymail.com"
- PaymailAddress = "address@paymail.com"
- // PubKey ex. "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"
- PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"
-)
-
-// MarshallForTestHandler its marshaling test handler
-func MarshallForTestHandler(object any) string {
- json, err := json.Marshal(object)
- if err != nil {
- // as this is just for tests, empty string will make the tests fail,
- // so it's acceptable as an "error" here, in case there's a problem with marshall
- return ""
- }
- return string(json)
-}
-
-// TestMetadata model for metadata
-var TestMetadata = map[string]any{"test-key": "test-value"}
-
-// Xpub model for testing
-var Xpub = &models.Xpub{
- Model: common.Model{Metadata: TestMetadata},
- ID: "cba0be1e753a7609e1a2f792d2e80ea6fce241be86f0690ec437377477809ccc",
- CurrentBalance: 16680,
- NextInternalNum: 2,
- NextExternalNum: 1,
-}
-
-// AccessKey model for testing
-var AccessKey = &models.AccessKey{
- Model: common.Model{Metadata: TestMetadata},
- ID: "access-key-id",
- XpubID: Xpub.ID,
- Key: AccessKeyString,
-}
-
-// Destination model for testing
-var Destination = &models.Destination{
- Model: common.Model{Metadata: TestMetadata},
- ID: "90d10acb85f37dd009238fe7ec61a1411725825c82099bd8432fcb47ad8326ce",
- XpubID: Xpub.ID,
- LockingScript: "76a9140e0eb4911d79e9b7683f268964f595b66fa3604588ac",
- Type: "pubkeyhash",
- Chain: 1,
- Num: 19,
- Address: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa",
- DraftID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df",
-}
-
-// Transaction model for testing
-var Transaction = &models.Transaction{
- Model: common.Model{Metadata: TestMetadata},
- ID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda",
- Hex: "0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000",
- XpubInIDs: []string{Xpub.ID},
- XpubOutIDs: []string{Xpub.ID},
- BlockHash: "00000000000000000896d2b93efa4476c4bd47ed7a554aeac6b38044745a6257",
- BlockHeight: 825599,
- Fee: 97,
- NumberOfInputs: 4,
- NumberOfOutputs: 2,
- DraftID: "fe6fe12c25b81106b7332d58fe87dab7bc6e56c8c21ca45b4de05f673f3f653c",
- TotalValue: 6955,
- OutputValue: 1725,
- Outputs: map[string]int64{"680d975a403fd9ec90f613e87d17802c029d2d930df1c8373cdcdda2f536a1c0": 62},
- Status: "confirmed",
- TransactionDirection: "incoming",
-}
-
-// DraftTx model for testing
-var DraftTx = &models.DraftTransaction{
- Model: common.Model{Metadata: TestMetadata},
- ID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df",
- Hex: "010000000123462f14e60556718916a8cff9dbf2258195a928777c0373200dba1cee105bdb0100000000ffffffff020c000000000000001976a914c4b15e7f65e3e6a062c1d21b7f1d7d2cd3b18e8188ac0b000000000000001976a91455873fd2baa7b51a624f6416b1d824939d99151a88ac00000000",
- XpubID: Xpub.ID,
- Configuration: models.TransactionConfig{
- ChangeDestinations: []*models.Destination{Destination},
- ChangeStrategy: "",
- ChangeMinimumSatoshis: 0,
- ChangeNumberOfDestinations: 0,
- ChangeSatoshis: 11,
- Fee: 1,
- FeeUnit: &models.FeeUnit{
- Satoshis: 1,
- Bytes: 1000,
- },
- FromUtxos: []*models.UtxoPointer{{
- TransactionID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda",
- OutputIndex: 1,
- }},
- IncludeUtxos: []*models.UtxoPointer{{
- TransactionID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda",
- OutputIndex: 1,
- }},
- Inputs: []*models.TransactionInput{{
- Utxo: models.Utxo{
- UtxoPointer: models.UtxoPointer{
- TransactionID: "db5b10ee1cba0d2073037c7728a9958125f2dbf9cfa81689715605e6142f4623",
- OutputIndex: 1,
- },
- ID: "041479f86c475603fd510431cf702bc8c9849a9c350390eb86b467d82a13cc24",
- XpubID: "9fe44728bf16a2dde3748f72cc65ea661f3bf18653b320d31eafcab37cf7fb36",
- Satoshis: 24,
- ScriptPubKey: "76a914673d3a53dade2723c48b446578681e253b5c548b88ac",
- Type: "pubkeyhash",
- DraftID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df",
- SpendingTxID: "",
- },
- Destination: *Destination,
- }},
- Outputs: []*models.TransactionOutput{
- {
- PaymailP4: &models.PaymailP4{
- Alias: "dorzepowski",
- Domain: "damiano.4chain.space",
- FromPaymail: "test3@kuba.4chain.space",
- Note: "paymail_note",
- PubKey: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG",
- ReceiveEndpoint: "https://damiano.serveo.net/v1/bsvalias/receive-transaction/{alias}@{domain.tld}",
- ReferenceID: "9b48dde1821fa82cf797372a297363c8",
- ResolutionType: "p2p",
- },
- Satoshis: 12,
- Scripts: []*models.ScriptOutput{{
- Address: "1Jw1vRUq6pYqiMBAT6x3wBfebXCrXv6Qbr",
- Satoshis: 12,
- Script: "76a914c4b15e7f65e3e6a062c1d21b7f1d7d2cd3b18e8188ac",
- ScriptType: "pubkeyhash",
- }},
- To: "pubkeyhash",
- UseForChange: false,
- },
- {
- Satoshis: 11,
- Scripts: []*models.ScriptOutput{{
- Address: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa",
- Satoshis: 11,
- Script: "76a91455873fd2baa7b51a624f6416b1d824939d99151a88ac",
- ScriptType: "pubkeyhash",
- }},
- To: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa",
- },
- },
- SendAllTo: &models.TransactionOutput{
- OpReturn: &models.OpReturn{
- Hex: "0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000",
- HexParts: []string{"0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000"},
- Map: &models.MapProtocol{
- App: "app_protocol",
- Keys: map[string]interface{}{"test-key": "test-value"},
- Type: "app_protocol_type",
- },
- StringParts: []string{"string", "parts"},
- },
- PaymailP4: &models.PaymailP4{
- Alias: "alias",
- Domain: "domain.tld",
- FromPaymail: "alias@paymail.com",
- Note: "paymail_note",
- PubKey: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG",
- ReceiveEndpoint: "https://bsvalias.example.org/alias@domain.tld/payment-destination-response",
- ReferenceID: "3d7c2ca83a46",
- ResolutionType: "resolution_type",
- },
- Satoshis: 1220,
- Script: "script",
- Scripts: []*models.ScriptOutput{{
- Address: "12HL5RyEy3Rt6SCwxgpiFSTigem1Pzbq22",
- Satoshis: 1220,
- Script: "script",
- ScriptType: "pubkeyhash",
- }},
- To: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG",
- UseForChange: false,
- },
- Sync: &models.SyncConfig{
- Broadcast: true,
- BroadcastInstant: true,
- PaymailP2P: true,
- SyncOnChain: true,
- },
- },
- Status: "draft",
- FinalTxID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda",
-}
-
-// Contact model for testing
-var Contact = &models.Contact{
- ID: "68af358bde7d8641621c7dd3de1a276c9a62cfa9e2d0740494519f1ba61e2f4a",
- FullName: "Test User",
- Paymail: "test@spv-wallet.com",
- PubKey: "xpub661MyMwAqRbcGpZVrSHU...",
- Status: responsemodels.ContactNotConfirmed,
-}
diff --git a/fixtures/spv_wallet.go b/fixtures/spv_wallet.go
deleted file mode 100644
index fa6c009f..00000000
--- a/fixtures/spv_wallet.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package fixtures
-
-import (
- "slices"
-
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-const (
- SPVWalletURL = "http://localhost:3003/api/v1"
-)
-
-// MockedSPVWalletData is mocked merkle roots data on spv-wallet side
-var MockedSPVWalletData = []models.MerkleRoot{
- {
- BlockHeight: 0,
- MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
- },
- {
- BlockHeight: 1,
- MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
- },
- {
- BlockHeight: 2,
- MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
- },
- {
- BlockHeight: 3,
- MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644",
- },
- {
- BlockHeight: 4,
- MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a",
- },
- {
- BlockHeight: 5,
- MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1",
- },
- {
- BlockHeight: 6,
- MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37",
- },
- {
- BlockHeight: 7,
- MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f",
- },
- {
- BlockHeight: 8,
- MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3",
- },
- {
- BlockHeight: 9,
- MerkleRoot: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c9",
- },
- {
- BlockHeight: 10,
- MerkleRoot: "d3ad39fa52a89997ac7381c95eeffeaf40b66af7a57e9eba144be0a175a12b11",
- },
- {
- BlockHeight: 11,
- MerkleRoot: "f8325d8f7fa5d658ea143629288d0530d2710dc9193ddc067439de803c37066e",
- },
- {
- BlockHeight: 12,
- MerkleRoot: "3b96bb7e197ef276b85131afd4a09c059cc368133a26ca04ebffb0ab4f75c8b8",
- },
- {
- BlockHeight: 13,
- MerkleRoot: "9962d5c704ec27243364cbe9d384808feeac1c15c35ac790dffd1e929829b271",
- },
- {
- BlockHeight: 14,
- MerkleRoot: "e1afd89295b68bc5247fe0ca2885dd4b8818d7ce430faa615067d7bab8640156",
- },
-}
-
-// LastMockedMerkleRoot returns last merkleroot value from MockedSPVWalletData
-func LastMockedMerkleRoot() models.MerkleRoot {
- return MockedSPVWalletData[len(MockedSPVWalletData)-1]
-}
-
-// MockedMerkleRootsAPIResponseFn is a mock of SPV-Wallet it will return a paged response of merkle roots since last evaluated merkle root
-func MockedMerkleRootsAPIResponseFn(lastMerkleRoot string) models.ExclusiveStartKeyPage[[]models.MerkleRoot] {
- if lastMerkleRoot == "" {
- return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
- Content: MockedSPVWalletData,
- Page: models.ExclusiveStartKeyPageInfo{
- LastEvaluatedKey: "",
- TotalElements: len(MockedSPVWalletData),
- Size: len(MockedSPVWalletData),
- },
- }
- }
-
- lastMerkleRootIdx := slices.IndexFunc(MockedSPVWalletData, func(mr models.MerkleRoot) bool {
- return mr.MerkleRoot == lastMerkleRoot
- })
-
- // handle case when lastMerkleRoot is already highest in the servers database
- if lastMerkleRootIdx == len(MockedSPVWalletData)-1 {
- return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
- Content: []models.MerkleRoot{},
- Page: models.ExclusiveStartKeyPageInfo{
- LastEvaluatedKey: "",
- TotalElements: len(MockedSPVWalletData),
- Size: 0,
- },
- }
- }
-
- content := MockedSPVWalletData[lastMerkleRootIdx+1:]
- lastEvaluatedKey := content[len(content)-1].MerkleRoot
-
- if lastEvaluatedKey == MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot {
- lastEvaluatedKey = ""
- }
-
- return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
- Content: content,
- Page: models.ExclusiveStartKeyPageInfo{
- LastEvaluatedKey: lastEvaluatedKey,
- TotalElements: len(MockedSPVWalletData),
- Size: len(content),
- },
- }
-}
diff --git a/fixtures/sync_merkleroots.go b/fixtures/sync_merkleroots.go
deleted file mode 100644
index 430bdc64..00000000
--- a/fixtures/sync_merkleroots.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package fixtures
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "time"
-
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-// simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method
-type DB struct {
- MerkleRoots []models.MerkleRoot
-}
-
-func (db *DB) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error {
- db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...)
- return nil
-}
-
-func (db *DB) GetLastMerkleRoot() string {
- if len(db.MerkleRoots) == 0 {
- return ""
- }
- return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot
-}
-
-// CreateRepository creates a simulated repository a client passes to SyncMerkleRoots()
-func CreateRepository(merkleRoots []models.MerkleRoot) *DB {
- return &DB{
- MerkleRoots: merkleRoots,
- }
-}
-
-func sendJSONResponse(data interface{}, w *http.ResponseWriter) {
- (*w).Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(*w).Encode(data); err != nil {
- (*w).WriteHeader(http.StatusInternalServerError)
- }
-}
-
-func MockMerkleRootsAPIResponseNormal() *httptest.Server {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet:
- lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey")
- sendJSONResponse(MockedMerkleRootsAPIResponseFn(lastEvaluatedKey), &w)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
-
- return server
-}
-
-func MockMerkleRootsAPIResponseDelayed() *httptest.Server {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet:
- lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey")
- // it is to limit the result up to 3 merkle roots per request to ensure
- // that the sync merkleroots will loop more than once and hit the timeout
- all := MockedMerkleRootsAPIResponseFn(lastEvaluatedKey)
- if len(all.Content) > 3 {
- all.Content = all.Content[:3]
- }
-
- all.Page.Size = len(all.Content)
-
- if len(all.Content) > 0 {
- all.Page.LastEvaluatedKey = all.Content[len(all.Content)-1].MerkleRoot
- } else {
- all.Page.LastEvaluatedKey = ""
- }
-
- time.Sleep(50 * time.Millisecond)
- sendJSONResponse(all, &w)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
-
- return server
-}
-
-func MockMerkleRootsAPIResponseStale() *httptest.Server {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet:
- staleLastEvaluatedKeyResponse := models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
- Content: []models.MerkleRoot{
- {
- MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
- BlockHeight: 0,
- },
- {
- MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
- BlockHeight: 1,
- },
- {
- MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
- BlockHeight: 2,
- },
- },
- Page: models.ExclusiveStartKeyPageInfo{
- LastEvaluatedKey: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
- Size: 3,
- TotalElements: len(MockedSPVWalletData),
- },
- }
- sendJSONResponse(staleLastEvaluatedKeyResponse, &w)
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
-
- return server
-}
diff --git a/go.mod b/go.mod
index d0c72bcd..6911a9a6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,20 +3,27 @@ module github.com/bitcoin-sv/spv-wallet-go-client
go 1.22.5
require (
- github.com/bitcoin-sv/go-sdk v1.1.16
+ github.com/bitcoin-sv/go-sdk v1.1.9
github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39
github.com/pquerna/otp v1.4.0
github.com/stretchr/testify v1.10.0
)
require (
- github.com/boombuler/barcode v1.0.2 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/kr/pretty v0.3.1 // indirect
+ github.com/rogpeppe/go-internal v1.12.0 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ golang.org/x/net v0.27.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/go-resty/resty/v2 v2.15.3
+ github.com/jarcoal/httpmock v1.3.1
github.com/pkg/errors v0.9.1 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/rogpeppe/go-internal v1.11.0 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/crypto v0.26.0 // indirect
- gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index fea24d83..09d6349e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,14 +1,17 @@
-github.com/bitcoin-sv/go-sdk v1.1.16 h1:n2X0RiENFGD/1fQ/1y6osbostRB7I/xq9I7tcIKcCPY=
-github.com/bitcoin-sv/go-sdk v1.1.16/go.mod h1:3CsNdEDBwB+SIv6UBcJPC9bTvPqxQvg3GULt7wsuL58=
+github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qtjc=
+github.com/bitcoin-sv/go-sdk v1.1.9/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4=
github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39 h1:qo74o72mcdj7AYJoCq7RG3enHJiqtbkFEY9uXvEEG2M=
github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.39/go.mod h1:UdY5AGsO9IomUEYSPilcSY+3BTQRJswdfZNveLt6LZQ=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
-github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-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/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/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8=
+github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=
+github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
+github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -16,22 +19,31 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
+github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+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/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
+golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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=
diff --git a/http.go b/http.go
deleted file mode 100644
index 2f372b06..00000000
--- a/http.go
+++ /dev/null
@@ -1,1200 +0,0 @@
-package walletclient
-
-import (
- "bytes"
- "context"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "net/http"
- "strconv"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
- "github.com/bitcoin-sv/spv-wallet-go-client/utils"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/bitcoin-sv/spv-wallet/models/filter"
-)
-
-// SetSignRequest turn the signing of the http request on or off
-func (wc *WalletClient) SetSignRequest(signRequest bool) {
- wc.signRequest = signRequest
-}
-
-// IsSignRequest return whether to sign all requests
-func (wc *WalletClient) IsSignRequest() bool {
- return wc.signRequest
-}
-
-// SetAdminKey set the admin key
-func (wc *WalletClient) SetAdminKey(adminKey *bip32.ExtendedKey) {
- wc.adminXPriv = adminKey
-}
-
-// GetXPub will get the xpub of the current xpub
-func (wc *WalletClient) GetXPub(ctx context.Context) (*models.Xpub, error) {
- var xPub models.Xpub
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/xpub", nil, wc.xPriv, true, &xPub,
- ); err != nil {
- return nil, err
- }
-
- return &xPub, nil
-}
-
-// UpdateXPubMetadata update the metadata of the logged in xpub
-func (wc *WalletClient) UpdateXPubMetadata(ctx context.Context, metadata map[string]any) (*models.Xpub, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var xPub models.Xpub
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/xpub", jsonStr, wc.xPriv, true, &xPub,
- ); err != nil {
- return nil, err
- }
-
- return &xPub, nil
-}
-
-// GetAccessKey will get an access key by id
-func (wc *WalletClient) GetAccessKey(ctx context.Context, id string) (*models.AccessKey, error) {
- var accessKey models.AccessKey
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/access-key?"+FieldID+"="+id, nil, wc.xPriv, true, &accessKey,
- ); err != nil {
- return nil, err
- }
-
- return &accessKey, nil
-}
-
-// GetAccessKeys will get all access keys matching the metadata filter
-func (wc *WalletClient) GetAccessKeys(
- ctx context.Context,
- conditions *filter.AccessKeyFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.AccessKey, error) {
- return Search[filter.AccessKeyFilter, []*models.AccessKey](
- ctx, http.MethodPost,
- "/access-key/search",
- wc.xPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// GetAccessKeysCount will get the count of access keys
-func (wc *WalletClient) GetAccessKeysCount(
- ctx context.Context,
- conditions *filter.AccessKeyFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.AccessKeyFilter](
- ctx, http.MethodPost,
- "/access-key/count",
- wc.xPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// RevokeAccessKey will revoke an access key by id
-func (wc *WalletClient) RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, error) {
- var accessKey models.AccessKey
- if err := wc.doHTTPRequest(
- ctx, http.MethodDelete, "/access-key?"+FieldID+"="+id, nil, wc.xPriv, true, &accessKey,
- ); err != nil {
- return nil, err
- }
-
- return &accessKey, nil
-}
-
-// CreateAccessKey will create new access key
-func (wc *WalletClient) CreateAccessKey(ctx context.Context, metadata map[string]any) (*models.AccessKey, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
- var accessKey models.AccessKey
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/access-key", jsonStr, wc.xPriv, true, &accessKey,
- ); err != nil {
- return nil, err
- }
-
- return &accessKey, nil
-}
-
-// GetDestinationByID will get a destination by id
-func (wc *WalletClient) GetDestinationByID(ctx context.Context, id string) (*models.Destination, error) {
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, fmt.Sprintf("/destination?%s=%s", FieldID, id), nil, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// GetDestinationByAddress will get a destination by address
-func (wc *WalletClient) GetDestinationByAddress(ctx context.Context, address string) (*models.Destination, error) {
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/destination?"+FieldAddress+"="+address, nil, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// GetDestinationByLockingScript will get a destination by locking script
-func (wc *WalletClient) GetDestinationByLockingScript(ctx context.Context, lockingScript string) (*models.Destination, error) {
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/destination?"+FieldLockingScript+"="+lockingScript, nil, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// GetDestinations will get all destinations matching the metadata filter
-func (wc *WalletClient) GetDestinations(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any, queryParams *filter.QueryParams) ([]*models.Destination, error) {
- return Search[filter.DestinationFilter, []*models.Destination](
- ctx, http.MethodPost,
- "/destination/search",
- wc.xPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// GetDestinationsCount will get the count of destinations matching the metadata filter
-func (wc *WalletClient) GetDestinationsCount(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any) (int64, error) {
- return Count(
- ctx,
- http.MethodPost,
- "/destination/count",
- wc.xPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// NewDestination will create a new destination and return it
-func (wc *WalletClient) NewDestination(ctx context.Context, metadata map[string]any) (*models.Destination, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/destination", jsonStr, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// UpdateDestinationMetadataByID updates the destination metadata by id
-func (wc *WalletClient) UpdateDestinationMetadataByID(ctx context.Context, id string, metadata map[string]any) (*models.Destination, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldID: id,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// UpdateDestinationMetadataByAddress updates the destination metadata by address
-func (wc *WalletClient) UpdateDestinationMetadataByAddress(ctx context.Context, address string, metadata map[string]any) (*models.Destination, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldAddress: address,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// UpdateDestinationMetadataByLockingScript updates the destination metadata by locking script
-func (wc *WalletClient) UpdateDestinationMetadataByLockingScript(ctx context.Context, lockingScript string, metadata map[string]any) (*models.Destination, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldLockingScript: lockingScript,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var destination models.Destination
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination,
- ); err != nil {
- return nil, err
- }
-
- return &destination, nil
-}
-
-// GetTransaction will get a transaction by ID
-func (wc *WalletClient) GetTransaction(ctx context.Context, txID string) (*models.Transaction, error) {
- var transaction models.Transaction
- if err := wc.doHTTPRequest(ctx, http.MethodGet, "/transaction?"+FieldID+"="+txID, nil, wc.xPriv, wc.signRequest, &transaction); err != nil {
- return nil, err
- }
-
- return &transaction, nil
-}
-
-// GetTransactions will get transactions by conditions
-func (wc *WalletClient) GetTransactions(
- ctx context.Context,
- conditions *filter.TransactionFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.Transaction, error) {
- return Search[filter.TransactionFilter, []*models.Transaction](
- ctx, http.MethodPost,
- "/transaction/search",
- wc.xPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// GetTransactionsCount get number of user transactions
-func (wc *WalletClient) GetTransactionsCount(
- ctx context.Context,
- conditions *filter.TransactionFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.TransactionFilter](
- ctx, http.MethodPost,
- "/transaction/count",
- wc.xPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// DraftToRecipients is a draft transaction to a slice of recipients
-func (wc *WalletClient) DraftToRecipients(ctx context.Context, recipients []*Recipients, metadata map[string]any) (*models.DraftTransaction, error) {
- outputs := make([]map[string]interface{}, 0)
- for _, recipient := range recipients {
- outputs = append(outputs, map[string]interface{}{
- FieldTo: recipient.To,
- FieldSatoshis: recipient.Satoshis,
- FieldOpReturn: recipient.OpReturn,
- })
- }
-
- return wc.createDraftTransaction(ctx, map[string]interface{}{
- FieldConfig: map[string]interface{}{
- FieldOutputs: outputs,
- },
- FieldMetadata: metadata,
- })
-}
-
-// DraftTransaction is a draft transaction
-func (wc *WalletClient) DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, metadata map[string]any) (*models.DraftTransaction, error) {
- return wc.createDraftTransaction(ctx, map[string]interface{}{
- FieldConfig: transactionConfig,
- FieldMetadata: metadata,
- })
-}
-
-// createDraftTransaction will create a draft transaction
-func (wc *WalletClient) createDraftTransaction(ctx context.Context,
- jsonData map[string]interface{},
-) (*models.DraftTransaction, error) {
- jsonStr, err := json.Marshal(jsonData)
- if err != nil {
- return nil, WrapError(err)
- }
-
- var draftTransaction *models.DraftTransaction
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/transaction", jsonStr, wc.xPriv, true, &draftTransaction,
- ); err != nil {
- return nil, err
- }
- if draftTransaction == nil {
- return nil, ErrCouldNotFindDraftTransaction
- }
-
- return draftTransaction, nil
-}
-
-// RecordTransaction will record a transaction
-func (wc *WalletClient) RecordTransaction(ctx context.Context, hex, referenceID string, metadata map[string]any) (*models.Transaction, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldHex: hex,
- FieldReferenceID: referenceID,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var transaction models.Transaction
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/transaction/record", jsonStr, wc.xPriv, wc.signRequest, &transaction,
- ); err != nil {
- return nil, err
- }
-
- return &transaction, nil
-}
-
-// UpdateTransactionMetadata update the metadata of a transaction
-func (wc *WalletClient) UpdateTransactionMetadata(ctx context.Context, txID string, metadata map[string]any) (*models.Transaction, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldID: txID,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var transaction models.Transaction
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/transaction", jsonStr, wc.xPriv, wc.signRequest, &transaction,
- ); err != nil {
- return nil, err
- }
-
- return &transaction, nil
-}
-
-// SetSignatureFromAccessKey will set the signature on the header for the request from an access key
-func SetSignatureFromAccessKey(header *http.Header, privateKeyHex, bodyString string) error {
- // Create the signature
- authData, err := createSignatureAccessKey(privateKeyHex, bodyString)
- if err != nil {
- return WrapError(err)
- }
-
- // Set the auth header
- header.Set(models.AuthAccessKey, authData.AccessKey)
-
- setSignatureHeaders(header, authData)
-
- return nil
-}
-
-// GetUtxo will get a utxo by transaction ID
-func (wc *WalletClient) GetUtxo(ctx context.Context, txID string, outputIndex uint32) (*models.Utxo, error) {
- outputIndexStr := strconv.FormatUint(uint64(outputIndex), 10)
-
- url := fmt.Sprintf("/utxo?%s=%s&%s=%s", FieldTransactionID, txID, FieldOutputIndex, outputIndexStr)
-
- var utxo models.Utxo
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, url, nil, wc.xPriv, true, &utxo,
- ); err != nil {
- return nil, err
- }
-
- return &utxo, nil
-}
-
-// GetUtxos will get a list of utxos filtered by conditions and metadata
-func (wc *WalletClient) GetUtxos(ctx context.Context, conditions *filter.UtxoFilter, metadata map[string]any, queryParams *filter.QueryParams) ([]*models.Utxo, error) {
- return Search[filter.UtxoFilter, []*models.Utxo](
- ctx, http.MethodPost,
- "/utxo/search",
- wc.xPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// GetUtxosCount will get the count of utxos filtered by conditions and metadata
-func (wc *WalletClient) GetUtxosCount(ctx context.Context, conditions *filter.UtxoFilter, metadata map[string]any) (int64, error) {
- return Count[filter.UtxoFilter](
- ctx, http.MethodPost,
- "/utxo/count",
- wc.xPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// createSignatureAccessKey will create a signature for the given access key & body contents
-func createSignatureAccessKey(privateKeyHex, bodyString string) (payload *models.AuthPayload, err error) {
- // No key?
- if privateKeyHex == "" {
- err = CreateErrorResponse("error-unauthorized-missing-access-key", "missing access key")
- return
- }
-
- var privateKey *ec.PrivateKey
- if privateKey, err = ec.PrivateKeyFromHex(
- privateKeyHex,
- ); err != nil {
- return
- }
- publicKey := privateKey.PubKey()
-
- // Get the AccessKey
- payload = new(models.AuthPayload)
- payload.AccessKey = hex.EncodeToString(publicKey.Compressed())
-
- // auth_nonce is a random unique string to seed the signing message
- // this can be checked server side to make sure the request is not being replayed
- payload.AuthNonce, err = utils.RandomHex(32)
- if err != nil {
- return nil, err
- }
-
- return createSignatureCommon(payload, bodyString, privateKey)
-}
-
-// doHTTPRequest will create and submit the HTTP request
-func (wc *WalletClient) doHTTPRequest(ctx context.Context, method string, path string,
- rawJSON []byte, xPriv *bip32.ExtendedKey, sign bool, responseJSON interface{},
-) error {
- req, err := http.NewRequestWithContext(ctx, method, wc.server+path, bytes.NewBuffer(rawJSON))
- if err != nil {
- return WrapError(err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- if xPriv != nil {
- err := wc.authenticateWithXpriv(sign, req, xPriv, rawJSON)
- if err != nil {
- return err
- }
- } else {
- err := wc.authenticateWithAccessKey(req, rawJSON)
- if err != nil {
- return err
- }
- }
-
- var resp *http.Response
- defer func() {
- if resp != nil && resp.Body != nil {
- _ = resp.Body.Close()
- }
- }()
- if resp, err = wc.httpClient.Do(req); err != nil {
- return WrapError(err)
- }
- if resp.StatusCode >= http.StatusBadRequest {
- return WrapResponseError(resp)
- }
-
- if responseJSON == nil {
- return nil
- }
-
- err = json.NewDecoder(resp.Body).Decode(&responseJSON)
- if err != nil {
- return WrapError(err)
- }
- return nil
-}
-
-func (wc *WalletClient) authenticateWithXpriv(sign bool, req *http.Request, xPriv *bip32.ExtendedKey, rawJSON []byte) error {
- if sign {
- if err := addSignature(&req.Header, xPriv, string(rawJSON)); err != nil {
- return err
- }
- } else {
- var xPub string
- xPub, err := bip32.GetExtendedPublicKey(xPriv)
- if err != nil {
- return WrapError(err)
- }
- req.Header.Set(models.AuthHeader, xPub)
- req.Header.Set("", xPub)
- }
- return nil
-}
-
-func (wc *WalletClient) authenticateWithAccessKey(req *http.Request, rawJSON []byte) error {
- if wc.accessKey == nil {
- return ErrMissingAccessKey
- }
- return SetSignatureFromAccessKey(&req.Header, hex.EncodeToString(wc.accessKey.Serialize()), string(rawJSON))
-}
-
-// AcceptContact will accept the contact associated with the paymail
-func (wc *WalletClient) AcceptContact(ctx context.Context, paymail string) error {
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/contact/accepted/"+paymail, nil, wc.xPriv, wc.signRequest, nil,
- ); err != nil {
- return err
- }
-
- return nil
-}
-
-// RejectContact will reject the contact associated with the paymail
-func (wc *WalletClient) RejectContact(ctx context.Context, paymail string) error {
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/contact/rejected/"+paymail, nil, wc.xPriv, wc.signRequest, nil,
- ); err != nil {
- return err
- }
-
- return nil
-}
-
-// ConfirmContact will confirm the contact associated with the paymail
-func (wc *WalletClient) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error {
- isTotpValid, err := wc.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits)
- if err != nil {
- return WrapError(ErrTotpInvalid)
- }
-
- if !isTotpValid {
- return WrapError(ErrTotpInvalid)
- }
-
- if err := wc.doHTTPRequest(
- ctx, http.MethodPatch, "/contact/confirmed/"+contact.Paymail, nil, wc.xPriv, wc.signRequest, nil,
- ); err != nil {
- return err
- }
-
- return nil
-}
-
-// GetContacts will get contacts by conditions
-func (wc *WalletClient) GetContacts(ctx context.Context, conditions *filter.ContactFilter, metadata map[string]any, queryParams *filter.QueryParams) (*models.SearchContactsResponse, error) {
- return Search[filter.ContactFilter, *models.SearchContactsResponse](
- ctx, http.MethodPost,
- "/contact/search",
- wc.xPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// UpsertContact add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.
-func (wc *WalletClient) UpsertContact(ctx context.Context, paymail, fullName, requesterPaymail string, metadata map[string]any) (*models.Contact, error) {
- return wc.UpsertContactForPaymail(ctx, paymail, fullName, metadata, requesterPaymail)
-}
-
-// UpsertContactForPaymail add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.
-func (wc *WalletClient) UpsertContactForPaymail(ctx context.Context, paymail, fullName string, metadata map[string]any, requesterPaymail string) (*models.Contact, error) {
- payload := map[string]interface{}{
- "fullName": fullName,
- FieldMetadata: metadata,
- }
-
- if requesterPaymail != "" {
- payload["requesterPaymail"] = requesterPaymail
- }
-
- jsonStr, err := json.Marshal(payload)
- if err != nil {
- return nil, WrapError(err)
- }
-
- var result models.Contact
- if err := wc.doHTTPRequest(
- ctx, http.MethodPut, "/contact/"+paymail, jsonStr, wc.xPriv, wc.signRequest, &result,
- ); err != nil {
- return nil, err
- }
-
- return &result, nil
-}
-
-// GetSharedConfig gets the shared config
-func (wc *WalletClient) GetSharedConfig(ctx context.Context) (*models.SharedConfig, error) {
- var model *models.SharedConfig
-
- key := wc.xPriv
- if wc.adminXPriv != nil {
- key = wc.adminXPriv
- }
- if key == nil {
- return nil, WrapError(ErrMissingKey)
- }
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/shared-config", nil, key, true, &model,
- ); err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// AdminNewXpub will register an xPub
-func (wc *WalletClient) AdminNewXpub(ctx context.Context, rawXPub string, metadata map[string]any) error {
- // Adding a xpub needs to be signed by an admin key
- if wc.adminXPriv == nil {
- return WrapError(ErrAdminKey)
- }
-
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldMetadata: metadata,
- FieldXpubKey: rawXPub,
- })
- if err != nil {
- return WrapError(err)
- }
-
- var xPubData models.Xpub
-
- return wc.doHTTPRequest(
- ctx, http.MethodPost, "/admin/xpub", jsonStr, wc.adminXPriv, true, &xPubData,
- )
-}
-
-// AdminGetStatus get whether admin key is valid
-func (wc *WalletClient) AdminGetStatus(ctx context.Context) (bool, error) {
- var status bool
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/admin/status", nil, wc.adminXPriv, true, &status,
- ); err != nil {
- return false, err
- }
-
- return status, nil
-}
-
-// AdminGetStats get admin stats
-func (wc *WalletClient) AdminGetStats(ctx context.Context) (*models.AdminStats, error) {
- var stats *models.AdminStats
- if err := wc.doHTTPRequest(
- ctx, http.MethodGet, "/admin/stats", nil, wc.adminXPriv, true, &stats,
- ); err != nil {
- return nil, err
- }
-
- return stats, nil
-}
-
-// AdminGetAccessKeys get all access keys filtered by conditions
-func (wc *WalletClient) AdminGetAccessKeys(
- ctx context.Context,
- conditions *filter.AdminAccessKeyFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.AccessKey, error) {
- return Search[filter.AdminAccessKeyFilter, []*models.AccessKey](
- ctx, http.MethodPost,
- "/admin/access-keys/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetAccessKeysCount get a count of all the access keys filtered by conditions
-func (wc *WalletClient) AdminGetAccessKeysCount(
- ctx context.Context,
- conditions *filter.AdminAccessKeyFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.AdminAccessKeyFilter](
- ctx, http.MethodPost,
- "/admin/access-keys/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetBlockHeaders get all block headers filtered by conditions
-func (wc *WalletClient) AdminGetBlockHeaders(
- ctx context.Context,
- conditions map[string]interface{},
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.BlockHeader, error) {
- var models []*models.BlockHeader
- if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/block-headers/search", &models); err != nil {
- return nil, err
- }
-
- return models, nil
-}
-
-// AdminGetBlockHeadersCount get a count of all the block headers filtered by conditions
-func (wc *WalletClient) AdminGetBlockHeadersCount(
- ctx context.Context,
- conditions map[string]interface{},
- metadata map[string]any,
-) (int64, error) {
- return wc.adminCount(ctx, conditions, metadata, "/admin/block-headers/count")
-}
-
-// AdminGetDestinations get all block destinations filtered by conditions
-func (wc *WalletClient) AdminGetDestinations(ctx context.Context, conditions *filter.DestinationFilter,
- metadata map[string]any, queryParams *filter.QueryParams,
-) ([]*models.Destination, error) {
- return Search[filter.DestinationFilter, []*models.Destination](
- ctx, http.MethodPost,
- "/admin/destinations/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetDestinationsCount get a count of all the destinations filtered by conditions
-func (wc *WalletClient) AdminGetDestinationsCount(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any) (int64, error) {
- return Count(
- ctx,
- http.MethodPost,
- "/admin/destinations/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetPaymail get a paymail by address
-func (wc *WalletClient) AdminGetPaymail(ctx context.Context, address string) (*models.PaymailAddress, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldAddress: address,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var model *models.PaymailAddress
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/admin/paymail/get", jsonStr, wc.adminXPriv, true, &model,
- ); err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// AdminGetPaymails get all block paymails filtered by conditions
-func (wc *WalletClient) AdminGetPaymails(
- ctx context.Context,
- conditions *filter.AdminPaymailFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.PaymailAddress, error) {
- return Search[filter.AdminPaymailFilter, []*models.PaymailAddress](
- ctx, http.MethodPost,
- "/admin/paymails/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetPaymailsCount get a count of all the paymails filtered by conditions
-func (wc *WalletClient) AdminGetPaymailsCount(ctx context.Context, conditions *filter.AdminPaymailFilter, metadata map[string]any) (int64, error) {
- return Count(
- ctx, http.MethodPost,
- "/admin/paymails/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// AdminCreatePaymail create a new paymail for a xpub
-func (wc *WalletClient) AdminCreatePaymail(ctx context.Context, rawXPub string, address string, publicName string, avatar string) (*models.PaymailAddress, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldXpubKey: rawXPub,
- FieldAddress: address,
- FieldPublicName: publicName,
- FieldAvatar: avatar,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var model *models.PaymailAddress
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/admin/paymail/create", jsonStr, wc.adminXPriv, true, &model,
- ); err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// AdminDeletePaymail delete a paymail address from the database
-func (wc *WalletClient) AdminDeletePaymail(ctx context.Context, address string) error {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldAddress: address,
- })
- if err != nil {
- return WrapError(err)
- }
-
- if err := wc.doHTTPRequest(
- ctx, http.MethodDelete, "/admin/paymail/delete", jsonStr, wc.adminXPriv, true, nil,
- ); err != nil {
- return err
- }
-
- return nil
-}
-
-// AdminGetTransactions get all block transactions filtered by conditions
-func (wc *WalletClient) AdminGetTransactions(
- ctx context.Context,
- conditions *filter.TransactionFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.Transaction, error) {
- return Search[filter.TransactionFilter, []*models.Transaction](
- ctx, http.MethodPost,
- "/admin/transactions/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetTransactionsCount get a count of all the transactions filtered by conditions
-func (wc *WalletClient) AdminGetTransactionsCount(
- ctx context.Context,
- conditions *filter.TransactionFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.TransactionFilter](
- ctx, http.MethodPost,
- "/admin/transactions/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetUtxos get all block utxos filtered by conditions
-func (wc *WalletClient) AdminGetUtxos(
- ctx context.Context,
- conditions *filter.AdminUtxoFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
-) ([]*models.Utxo, error) {
- return Search[filter.AdminUtxoFilter, []*models.Utxo](
- ctx, http.MethodPost,
- "/admin/utxos/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetUtxosCount get a count of all the utxos filtered by conditions
-func (wc *WalletClient) AdminGetUtxosCount(
- ctx context.Context,
- conditions *filter.AdminUtxoFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.AdminUtxoFilter](
- ctx, http.MethodPost,
- "/admin/utxos/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetXPubs get all block xpubs filtered by conditions
-func (wc *WalletClient) AdminGetXPubs(ctx context.Context, conditions *filter.XpubFilter,
- metadata map[string]any, queryParams *filter.QueryParams,
-) ([]*models.Xpub, error) {
- return Search[filter.XpubFilter, []*models.Xpub](
- ctx, http.MethodPost,
- "/admin/xpubs/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminGetXPubsCount get a count of all the xpubs filtered by conditions
-func (wc *WalletClient) AdminGetXPubsCount(
- ctx context.Context,
- conditions *filter.XpubFilter,
- metadata map[string]any,
-) (int64, error) {
- return Count[filter.XpubFilter](
- ctx, http.MethodPost,
- "/admin/xpubs/count",
- wc.adminXPriv,
- conditions,
- metadata,
- wc.doHTTPRequest,
- )
-}
-
-func (wc *WalletClient) adminGetModels(
- ctx context.Context,
- conditions map[string]interface{},
- metadata map[string]any,
- queryParams *filter.QueryParams,
- path string,
- models interface{},
-) error {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldConditions: conditions,
- FieldMetadata: metadata,
- FieldQueryParams: queryParams,
- })
- if err != nil {
- return WrapError(err)
- }
-
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, path, jsonStr, wc.adminXPriv, true, &models,
- ); err != nil {
- return err
- }
-
- return nil
-}
-
-func (wc *WalletClient) adminCount(ctx context.Context, conditions map[string]interface{}, metadata map[string]any, path string) (int64, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldConditions: conditions,
- FieldMetadata: metadata,
- })
- if err != nil {
- return 0, WrapError(err)
- }
-
- var count int64
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, path, jsonStr, wc.adminXPriv, true, &count,
- ); err != nil {
- return 0, err
- }
-
- return count, nil
-}
-
-// AdminRecordTransaction will record a transaction as an admin
-func (wc *WalletClient) AdminRecordTransaction(ctx context.Context, hex string) (*models.Transaction, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- FieldHex: hex,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var transaction models.Transaction
- if err := wc.doHTTPRequest(
- ctx, http.MethodPost, "/admin/transactions/record", jsonStr, wc.adminXPriv, wc.signRequest, &transaction,
- ); err != nil {
- return nil, err
- }
-
- return &transaction, nil
-}
-
-// AdminGetContacts executes an HTTP POST request to search for contacts based on specified conditions, metadata, and query parameters.
-func (wc *WalletClient) AdminGetContacts(ctx context.Context, conditions *filter.AdminContactFilter, metadata map[string]any, queryParams *filter.QueryParams) (*models.SearchContactsResponse, error) {
- return Search[filter.AdminContactFilter, *models.SearchContactsResponse](
- ctx, http.MethodPost,
- "/admin/contact/search",
- wc.adminXPriv,
- conditions,
- metadata,
- queryParams,
- wc.doHTTPRequest,
- )
-}
-
-// AdminUpdateContact executes an HTTP PATCH request to update a specific contact's full name using their ID.
-func (wc *WalletClient) AdminUpdateContact(ctx context.Context, id, fullName string, metadata map[string]any) (*models.Contact, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- "fullName": fullName,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
- var contact models.Contact
- err = wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/%s", id), jsonStr, wc.adminXPriv, true, &contact)
- return &contact, WrapError(err)
-}
-
-// AdminCreateContact executes an HTTP POST request to create a new contact using the specified paymail, creator paymail, full name, and metadata.
-func (wc *WalletClient) AdminCreateContact(ctx context.Context, contactPaymail, creatorPaymail, fullName string, metadata map[string]any) (*models.Contact, error) {
- jsonStr, err := json.Marshal(map[string]interface{}{
- "creatorPaymail": creatorPaymail,
- "fullName": fullName,
- FieldMetadata: metadata,
- })
- if err != nil {
- return nil, WrapError(err)
- }
-
- var contact models.Contact
- err = wc.doHTTPRequest(ctx, http.MethodPost, fmt.Sprintf("/admin/contact/%s", contactPaymail), jsonStr, wc.adminXPriv, true, &contact)
- return &contact, WrapError(err)
-}
-
-// AdminDeleteContact executes an HTTP DELETE request to remove a contact using their ID.
-func (wc *WalletClient) AdminDeleteContact(ctx context.Context, id string) error {
- err := wc.doHTTPRequest(ctx, http.MethodDelete, fmt.Sprintf("/admin/contact/%s", id), nil, wc.adminXPriv, true, nil)
- return WrapError(err)
-}
-
-// AdminAcceptContact executes an HTTP PATCH request to mark a contact as accepted using their ID.
-func (wc *WalletClient) AdminAcceptContact(ctx context.Context, id string) (*models.Contact, error) {
- var contact models.Contact
- err := wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/accepted/%s", id), nil, wc.adminXPriv, true, &contact)
- return &contact, WrapError(err)
-}
-
-// AdminRejectContact executes an HTTP PATCH request to mark a contact as rejected using their ID.
-func (wc *WalletClient) AdminRejectContact(ctx context.Context, id string) (*models.Contact, error) {
- var contact models.Contact
- err := wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/rejected/%s", id), nil, wc.adminXPriv, true, &contact)
- return &contact, WrapError(err)
-}
-
-// AdminConfirmContacts executes an HTTP POST request to confirm a contact using their xPubs IDs and paymails.
-func (wc *WalletClient) AdminConfirmContacts(ctx context.Context, paymailA, paymailB string) error {
- jsonStr, err := json.Marshal(map[string]interface{}{
- "paymailA": paymailA,
- "paymailB": paymailB,
- })
- if err != nil {
- return WrapError(err)
- }
-
- err = wc.doHTTPRequest(ctx, http.MethodPost, "/admin/contacts/confirmations", jsonStr, wc.adminXPriv, true, nil)
- return WrapError(err)
-}
-
-// FinalizeTransaction will finalize the transaction
-func (wc *WalletClient) FinalizeTransaction(draft *models.DraftTransaction) (string, error) {
- res, err := GetSignedHex(draft, wc.xPriv)
- if err != nil {
- return "", WrapError(err)
- }
-
- return res, nil
-}
-
-// SendToRecipients send to recipients
-func (wc *WalletClient) SendToRecipients(ctx context.Context, recipients []*Recipients, metadata map[string]any) (*models.Transaction, error) {
- draft, err := wc.DraftToRecipients(ctx, recipients, metadata)
- if err != nil {
- return nil, err
- }
-
- var hex string
- if hex, err = wc.FinalizeTransaction(draft); err != nil {
- return nil, err
- }
-
- return wc.RecordTransaction(ctx, hex, draft.ID, metadata)
-}
-
-// AdminSubscribeWebhook subscribes to a webhook to receive notifications from spv-wallet
-func (wc *WalletClient) AdminSubscribeWebhook(ctx context.Context, webhookURL, tokenHeader, tokenValue string) error {
- requestModel := models.SubscribeRequestBody{
- URL: webhookURL,
- TokenHeader: tokenHeader,
- TokenValue: tokenValue,
- }
- rawJSON, err := json.Marshal(requestModel)
- if err != nil {
- return WrapError(err)
- }
- err = wc.doHTTPRequest(ctx, http.MethodPost, "/admin/webhooks/subscriptions", rawJSON, wc.adminXPriv, true, nil)
- return WrapError(err)
-}
-
-// AdminUnsubscribeWebhook unsubscribes from a webhook
-func (wc *WalletClient) AdminUnsubscribeWebhook(ctx context.Context, webhookURL string) error {
- requestModel := models.UnsubscribeRequestBody{
- URL: webhookURL,
- }
- rawJSON, err := json.Marshal(requestModel)
- if err != nil {
- return WrapError(err)
- }
- err = wc.doHTTPRequest(ctx, http.MethodDelete, "/admin/webhooks/subscriptions", rawJSON, wc.adminXPriv, true, nil)
- return err
-}
-
-// AdminGetWebhooks gets all webhooks
-func (wc *WalletClient) AdminGetWebhooks(ctx context.Context) ([]*models.Webhook, error) {
- var webhooks []*models.Webhook
- err := wc.doHTTPRequest(ctx, http.MethodGet, "/admin/webhooks/subscriptions", nil, wc.adminXPriv, true, &webhooks)
- if err != nil {
- return nil, WrapError(err)
- }
- return webhooks, nil
-}
diff --git a/internal/api/v1/admin/accesskeys/access_keys_api.go b/internal/api/v1/admin/accesskeys/access_keys_api.go
new file mode 100644
index 00000000..12e787e1
--- /dev/null
+++ b/internal/api/v1/admin/accesskeys/access_keys_api.go
@@ -0,0 +1,52 @@
+package accesskeys
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "/api/v1/admin/users/keys"
+ api = "Admin Access Keys API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) AccessKeys(ctx context.Context, opts ...queries.QueryOption[filter.AdminAccessKeyFilter]) (*queries.AccessKeyPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build access keys query params: %w", err)
+ }
+
+ var result queries.AccessKeyPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/accesskeys/access_keys_api_test.go b/internal/api/v1/admin/accesskeys/access_keys_api_test.go
new file mode 100644
index 00000000..45fc839a
--- /dev/null
+++ b/internal/api/v1/admin/accesskeys/access_keys_api_test.go
@@ -0,0 +1,73 @@
+package accesskeys_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/accesskeys/accesskeystest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const accessKeysURL = "/api/v1/admin/users/keys"
+
+func TestAccessKeyAPI_AccessKeys(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.AccessKeyPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/users/keys response: 200": {
+ expectedResponse: accesskeystest.ExpectedAccessKeyPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("accesskeystest/get_access_keys_200.json"),
+ },
+ "HTTP GET /api/v1/admin/users/keys response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/users/keys response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/users/keys str response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, accessKeysURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AdminAccessKeyFilter]{
+ queries.QueryWithPageFilter[filter.AdminAccessKeyFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AdminAccessKeyFilter{
+ AccessKeyFilter: filter.AccessKeyFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.AccessKeys(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/accesskeys/accesskeystest/access_keys_api_fixtures.go b/internal/api/v1/admin/accesskeys/accesskeystest/access_keys_api_fixtures.go
new file mode 100644
index 00000000..8bdda81b
--- /dev/null
+++ b/internal/api/v1/admin/accesskeys/accesskeystest/access_keys_api_fixtures.go
@@ -0,0 +1,38 @@
+package accesskeystest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedAccessKeyPage(t *testing.T) *queries.AccessKeyPage {
+ return &queries.AccessKeyPage{
+ Content: []*response.AccessKey{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-28T14:56:59.841638Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-28T14:56:59.841832Z"),
+ },
+ ID: "3a77c921-b881-4907-8dc6-3903700272cb",
+ XpubID: "cd6709cd-4f0e-464b-8d7d-0197e853f375",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-28T13:28:22.943632Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-28T13:28:22.943664Z"),
+ },
+ ID: "35aacdfd-5839-4125-9180-d33e798b1cde",
+ XpubID: "7c6c4462-626c-47f6-84bc-04044798a4bf",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 2,
+ Number: 1,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/admin/accesskeys/accesskeystest/get_access_keys_200.json b/internal/api/v1/admin/accesskeys/accesskeystest/get_access_keys_200.json
new file mode 100644
index 00000000..878014fb
--- /dev/null
+++ b/internal/api/v1/admin/accesskeys/accesskeystest/get_access_keys_200.json
@@ -0,0 +1,26 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-28T14:56:59.841638Z",
+ "updatedAt": "2024-11-28T14:56:59.841832Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "3a77c921-b881-4907-8dc6-3903700272cb",
+ "xpubId": "cd6709cd-4f0e-464b-8d7d-0197e853f375"
+ },
+ {
+ "createdAt": "2024-11-28T13:28:22.943632Z",
+ "updatedAt": "2024-11-28T13:28:22.943664Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "35aacdfd-5839-4125-9180-d33e798b1cde",
+ "xpubId": "7c6c4462-626c-47f6-84bc-04044798a4bf"
+ }
+ ],
+ "page": {
+ "size": 2,
+ "number": 1,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/contacts/contacts_api.go b/internal/api/v1/admin/contacts/contacts_api.go
new file mode 100644
index 00000000..12069685
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contacts_api.go
@@ -0,0 +1,108 @@
+package contacts
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/admin/contacts"
+ api = "Admin Contacts API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) CreateContact(ctx context.Context, cmd *commands.CreateContact) (*response.Contact, error) {
+ var result response.Contact
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetBody(cmd).
+ SetResult(&result).
+ Post(a.url.JoinPath(cmd.Paymail).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Contacts(ctx context.Context, opts ...queries.QueryOption[filter.AdminContactFilter]) (*queries.ContactsPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build admin contacts query params: %w", err)
+ }
+
+ var result queries.ContactsPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) UpdateContact(ctx context.Context, cmd *commands.UpdateContact) (*response.Contact, error) {
+ var result response.Contact
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(cmd).
+ Put(a.url.JoinPath(cmd.ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) ConfirmContacts(ctx context.Context, cmd *commands.ConfirmContacts) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetBody(cmd).
+ Post(a.url.JoinPath("confirmations").String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure :%w", err)
+ }
+ return nil
+}
+
+func (a *API) DeleteContact(ctx context.Context, ID string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Delete(a.url.JoinPath(ID).String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/contacts/contacts_api_test.go b/internal/api/v1/admin/contacts/contacts_api_test.go
new file mode 100644
index 00000000..07e86536
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contacts_api_test.go
@@ -0,0 +1,257 @@
+package contacts_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/contacts/contactstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ contactsURL = "/api/v1/admin/contacts"
+ id = "4d570959-dd85-4f53-bad1-18d0671761e9"
+)
+
+func TestContactsAPI_CreateContact(t *testing.T) {
+ paymail := "john.doe@test.4chain.space"
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Contact
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 200", paymail): {
+ expectedResponse: contactstest.ExpectedCreatedContact(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/post_contact_200.json"),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 404", paymail): {
+ expectedErr: testutils.NewResourceNotFoundSPVError(),
+ responder: testutils.NewResourceNotFoundSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 409", paymail): {
+ expectedErr: testutils.NewConflictRequestSPVError(),
+ responder: testutils.NewConflictRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/contacts/%s response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, contactsURL+"/"+paymail, tc.responder)
+
+ // when:
+ got, err := wallet.CreateContact(context.Background(), &commands.CreateContact{
+ CreatorPaymail: "admin@test.4chain.space",
+ Paymail: paymail,
+ FullName: "John Doe",
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_Contacts(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.ContactsPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/contacts response: 200": {
+ expectedResponse: contactstest.ExpectedContactsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/get_contacts_200.json"),
+ },
+ "HTTP GET /api/v1/admin/contacts response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/contacts response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/contacts str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AdminContactFilter]{
+ queries.QueryWithPageFilter[filter.AdminContactFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AdminContactFilter{
+ ContactFilter: filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Contacts(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_ConfirmContacts(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/admin/contacts/confirmations response: 200": {
+ responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, http.StatusText(http.StatusOK)),
+ },
+ "HTTP POST /api/v1/admin/contacts/confirmations response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/contacts/confirmations response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/contacts/confirmations str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL+"/confirmations")
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ err := wallet.ConfirmContacts(context.Background(), &commands.ConfirmContacts{
+ PaymailA: "alice@paymail.com",
+ PaymailB: "bob@paymail.com",
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestContactsAPI_ContactUpdate(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Contact
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP PUT /api/v1/admin/contacts/%s response: 200", id): {
+ expectedResponse: contactstest.ExpectedUpdatedUserContact(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/put_contact_update_200.json"),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/admin/contacts/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/admin/contacts/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/admin/contacts/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPut, url, tc.responder)
+
+ // when:
+ got, err := wallet.ContactUpdate(context.Background(), &commands.UpdateContact{
+ ID: id,
+ FullName: "John Doe Williams",
+ Metadata: map[string]any{"phoneNumber": "123456789"},
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_DeleteContact(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/contacts/%s response: 200", id): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE/api/v1/admin/contacts/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/contacts/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/contacts/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.DeleteContact(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/contacts/contactstest/contacts_api_fixtures.go b/internal/api/v1/admin/contacts/contactstest/contacts_api_fixtures.go
new file mode 100644
index 00000000..3c407c45
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contactstest/contacts_api_fixtures.go
@@ -0,0 +1,76 @@
+package contactstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedUpdatedUserContact(t *testing.T) *response.Contact {
+ return &response.Contact{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-28T13:34:52.11722Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-29T08:23:19.66093Z"),
+ Metadata: map[string]any{"phoneNumber": "123456789"},
+ },
+ ID: "4d570959-dd85-4f53-bad1-18d0671761e9",
+ FullName: "John Doe Williams",
+ Paymail: "john.doe.test@john.doe.test.4chain.space",
+ PubKey: "96843af4-fc9c-4778-945d-2131ac5b1a8a",
+ Status: "awaiting",
+ }
+}
+
+func ExpectedCreatedContact(t *testing.T) *response.Contact {
+ return &response.Contact{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-12-23T08:31:14.66249+01:00"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-23T07:31:14.661618Z"),
+ },
+ ID: "649da615-4442-4bcb-8d0c-612d35c841a8",
+ FullName: "John Doe",
+ Paymail: "john.doe@test.4chain.space",
+ PubKey: "4ccd70d0-d809-4aa8-98e9-76aa6bdd6017",
+ Status: "unconfirmed",
+ }
+}
+
+func ExpectedContactsPage(t *testing.T) *queries.ContactsPage {
+ return &queries.ContactsPage{
+ Content: []*response.Contact{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-28T14:58:13.262238Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-28T16:18:43.842434Z"),
+ },
+ ID: "7a5625ac-8256-454a-84a3-7f03f50cd7dc",
+ FullName: "John Doe",
+ Paymail: "john.doe.test@john.doe.4chain.space",
+ PubKey: "bbbb7a4e-a3f4-4ca4-800a-fdd8029eda37",
+ Status: "confirmed",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-28T14:58:13.029966Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-28T14:58:13.03002Z"),
+ Metadata: map[string]any{
+ "phoneNumber": "123456789",
+ },
+ },
+ ID: "d05d2388-3c16-426d-98f1-ced9d9c5f4e1",
+ FullName: "Jane Doe",
+ Paymail: "jane.doe.jane@john.doe.4chain.space",
+ PubKey: "ee191d63-1619-4fd3-ae3d-2202cfab751d",
+ Status: "unconfirmed",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 50,
+ Number: 1,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/admin/contacts/contactstest/get_contacts_200.json b/internal/api/v1/admin/contacts/contactstest/get_contacts_200.json
new file mode 100644
index 00000000..cf1ee683
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contactstest/get_contacts_200.json
@@ -0,0 +1,34 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-28T14:58:13.262238Z",
+ "updatedAt": "2024-11-28T16:18:43.842434Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "7a5625ac-8256-454a-84a3-7f03f50cd7dc",
+ "fullName": "John Doe",
+ "paymail": "john.doe.test@john.doe.4chain.space",
+ "pubKey": "bbbb7a4e-a3f4-4ca4-800a-fdd8029eda37",
+ "status": "confirmed"
+ },
+ {
+ "createdAt": "2024-11-28T14:58:13.029966Z",
+ "updatedAt": "2024-11-28T14:58:13.03002Z",
+ "deletedAt": null,
+ "metadata": {
+ "phoneNumber": "123456789"
+ },
+ "id": "d05d2388-3c16-426d-98f1-ced9d9c5f4e1",
+ "fullName": "Jane Doe",
+ "paymail": "jane.doe.jane@john.doe.4chain.space",
+ "pubKey": "ee191d63-1619-4fd3-ae3d-2202cfab751d",
+ "status": "unconfirmed"
+ }
+ ],
+ "page": {
+ "size": 50,
+ "number": 1,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/contacts/contactstest/post_contact_200.json b/internal/api/v1/admin/contacts/contactstest/post_contact_200.json
new file mode 100644
index 00000000..418568a7
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contactstest/post_contact_200.json
@@ -0,0 +1,11 @@
+{
+ "createdAt": "2024-12-23T08:31:14.66249+01:00",
+ "updatedAt": "2024-12-23T07:31:14.661618Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "649da615-4442-4bcb-8d0c-612d35c841a8",
+ "fullName": "John Doe",
+ "paymail": "john.doe@test.4chain.space",
+ "pubKey": "4ccd70d0-d809-4aa8-98e9-76aa6bdd6017",
+ "status": "unconfirmed"
+}
diff --git a/internal/api/v1/admin/contacts/contactstest/put_contact_update_200.json b/internal/api/v1/admin/contacts/contactstest/put_contact_update_200.json
new file mode 100644
index 00000000..c7d884e7
--- /dev/null
+++ b/internal/api/v1/admin/contacts/contactstest/put_contact_update_200.json
@@ -0,0 +1,13 @@
+{
+ "createdAt": "2024-11-28T13:34:52.11722Z",
+ "updatedAt": "2024-11-29T08:23:19.66093Z",
+ "deletedAt": null,
+ "metadata": {
+ "phoneNumber": "123456789"
+ },
+ "id": "4d570959-dd85-4f53-bad1-18d0671761e9",
+ "fullName": "John Doe Williams",
+ "paymail": "john.doe.test@john.doe.test.4chain.space",
+ "pubKey": "96843af4-fc9c-4778-945d-2131ac5b1a8a",
+ "status": "awaiting"
+}
diff --git a/internal/api/v1/admin/invitations/invitations_api.go b/internal/api/v1/admin/invitations/invitations_api.go
new file mode 100644
index 00000000..39898a46
--- /dev/null
+++ b/internal/api/v1/admin/invitations/invitations_api.go
@@ -0,0 +1,49 @@
+package invitations
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/admin/invitations"
+ api = "Admin Invitations API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) AcceptInvitation(ctx context.Context, ID string) error {
+ URL := a.url.JoinPath(ID).String()
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Post(URL)
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) RejectInvitation(ctx context.Context, ID string) error {
+ URL := a.url.JoinPath(ID).String()
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Delete(URL)
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/invitations/invitations_api_test.go b/internal/api/v1/admin/invitations/invitations_api_test.go
new file mode 100644
index 00000000..0c8d4e47
--- /dev/null
+++ b/internal/api/v1/admin/invitations/invitations_api_test.go
@@ -0,0 +1,90 @@
+package invitations_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ invitationsURL = "/api/v1/admin/invitations"
+ id = "34d0b1f9-6d00-4bdb-ba2e-146a3cbadd35"
+)
+
+func TestInvitationsAPI_AcceptInvitation(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP POST /api/v1/admin/invitations/%s response: 200", id): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/invitations/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/invitations/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/admin/invitations/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, invitationsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // then:
+ err := wallet.AcceptInvitation(context.Background(), id)
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestInvitationsAPI_RejectInvitation(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/invitations/%s response: 200", id): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/invitations/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/invitations/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/invitations/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, invitationsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // then:
+ err := wallet.RejectInvitation(context.Background(), id)
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/paymails/paymails_api.go b/internal/api/v1/admin/paymails/paymails_api.go
new file mode 100644
index 00000000..b1845c4e
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymails_api.go
@@ -0,0 +1,109 @@
+package paymails
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/admin/paymails"
+ api = "Admin User Paymails API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) DeletePaymail(ctx context.Context, address string) error {
+ type body struct {
+ Address string `json:"address"`
+ }
+
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetBody(body{Address: address}).
+ Delete(a.url.JoinPath(address).String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) CreatePaymail(ctx context.Context, cmd *commands.CreatePaymail) (*response.PaymailAddress, error) {
+ var result response.PaymailAddress
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(cmd).
+ Post(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Paymail(ctx context.Context, ID string) (*response.PaymailAddress, error) {
+ var result response.PaymailAddress
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath(ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Paymails(ctx context.Context, opts ...queries.QueryOption[filter.AdminPaymailFilter]) (*queries.PaymailsPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build paymail address query params: %w", err)
+ }
+
+ var result queries.PaymailsPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
+
+func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter {
+ return &errutil.HTTPErrorFormatter{
+ Action: action,
+ API: api,
+ Err: err,
+ }
+}
diff --git a/internal/api/v1/admin/paymails/paymails_api_test.go b/internal/api/v1/admin/paymails/paymails_api_test.go
new file mode 100644
index 00000000..75bb7472
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymails_api_test.go
@@ -0,0 +1,205 @@
+package paymails_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/paymails/paymailstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ paymailsURL = "/api/v1/admin/paymails"
+ xpubID = "xpub22e6cba6-ef6e-432a-8612-63ac4b290ce9"
+ id = "98dbafe0-4e2b-4307-8fbf-c55209214bae"
+)
+
+func TestPaymailsAPI_DeletePaymail(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.PaymailAddress
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/paymails/%s response: 200", xpubID): {
+ expectedResponse: paymailstest.ExpectedCreatedPaymail(t),
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/paymails/%s response: 400", xpubID): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/paymails/%s response: 500", xpubID): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/admin/paymails/%s str response: 500", xpubID): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, paymailsURL, xpubID)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.DeletePaymail(context.Background(), xpubID)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestPaymailsAPI_CreatePaymail(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.PaymailAddress
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/admin/paymails response: 200": {
+ expectedResponse: paymailstest.ExpectedCreatedPaymail(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("paymailstest/post_paymail_200.json"),
+ },
+ "HTTP POST /api/v1/admin/paymails response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/paymails response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/paymails str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, paymailsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ got, err := wallet.CreatePaymail(context.Background(), &commands.CreatePaymail{
+ Key: xpubID,
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestPaymailsAPI_Paymails(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.PaymailsPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/paymails response: 200": {
+ expectedResponse: paymailstest.ExpectedPaymailsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("paymailstest/get_paymails_200.json"),
+ },
+ "HTTP GET /api/v1/admin/paymails response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/paymails response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/paymails str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, paymailsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AdminPaymailFilter]{
+ queries.QueryWithPageFilter[filter.AdminPaymailFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AdminPaymailFilter{
+ PaymailFilter: filter.PaymailFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Paymails(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestPaymailsAPI_Paymail(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.PaymailAddress
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP GET /api/v1/admin/paymails/%s response: 200", id): {
+ expectedResponse: paymailstest.ExpectedPaymail(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("paymailstest/get_paymail_200.json"),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/paymails/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/paymails/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/paymails/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, paymailsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.Paymail(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/paymails/paymailstest/get_paymail_200.json b/internal/api/v1/admin/paymails/paymailstest/get_paymail_200.json
new file mode 100644
index 00000000..7c15f641
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymailstest/get_paymail_200.json
@@ -0,0 +1,12 @@
+{
+ "createdAt": "2024-10-02T10:28:15.544234Z",
+ "updatedAt": "2024-10-02T10:34:54.836433Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "98dbafe0-4e2b-4307-8fbf-c55209214bae",
+ "xpubId": "0d71ac87-ef56-4b1a-8372-814481cface6",
+ "alias": "john.doe.test",
+ "domain": "john.doe.test.4chain.space",
+ "publicName": "john.doe.test",
+ "avatar": "http://localhost:3003/static/paymail/avatar.jpg"
+ }
diff --git a/internal/api/v1/admin/paymails/paymailstest/get_paymails_200.json b/internal/api/v1/admin/paymails/paymailstest/get_paymails_200.json
new file mode 100644
index 00000000..618bedd5
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymailstest/get_paymails_200.json
@@ -0,0 +1,34 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-18T06:50:07.144902Z",
+ "updatedAt": "2024-11-18T06:50:07.144932Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "31b80181-4d8b-4766-9bc7-76a1d9c6b44d",
+ "xpubId": "69245a3a-f9ed-4046-9acb-9d66c0b3750c",
+ "alias": "john.doe.test",
+ "domain": "john.doe.4chain.space",
+ "publicName": "John Doe",
+ "avatar": ""
+ },
+ {
+ "createdAt": "2024-11-08T15:10:44.688653Z",
+ "updatedAt": "2024-11-18T07:19:51.561691Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "ec91273e-9fb7-4f10-9ecb-d1848d238814",
+ "xpubId": "68026cb6-a549-45e8-97b1-11426bb16769",
+ "alias": "jane.doe.test",
+ "domain": "jane.doe.4chain.space",
+ "publicName": "Jane Doe",
+ "avatar": "http://localhost:3003/static/paymail/avatar.jpg"
+ }
+ ],
+ "page": {
+ "size": 10,
+ "number": 1,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/paymails/paymailstest/paymail_api_fixtures.go b/internal/api/v1/admin/paymails/paymailstest/paymail_api_fixtures.go
new file mode 100644
index 00000000..7231bf12
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymailstest/paymail_api_fixtures.go
@@ -0,0 +1,75 @@
+package paymailstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedCreatedPaymail(t *testing.T) *response.PaymailAddress {
+ return &response.PaymailAddress{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-12-02T10:22:45.263654Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-02T11:22:45.263664+01:00"),
+ },
+ ID: "069d0011-580e-4fc6-9f24-45471b732a8b",
+ XpubID: "22e6cba6-ef6e-432a-8612-63ac4b290ce9",
+ Alias: "john.doe.test",
+ Domain: "example.com",
+ PublicName: "john.doe.test",
+ Avatar: "",
+ }
+}
+
+func ExpectedPaymail(t *testing.T) *response.PaymailAddress {
+ return &response.PaymailAddress{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-02T10:28:15.544234Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-02T10:34:54.836433Z"),
+ },
+ ID: "98dbafe0-4e2b-4307-8fbf-c55209214bae",
+ XpubID: "0d71ac87-ef56-4b1a-8372-814481cface6",
+ Alias: "john.doe.test",
+ Domain: "john.doe.test.4chain.space",
+ PublicName: "john.doe.test",
+ Avatar: "http://localhost:3003/static/paymail/avatar.jpg",
+ }
+}
+
+func ExpectedPaymailsPage(t *testing.T) *queries.PaymailsPage {
+ return &queries.PaymailsPage{
+ Content: []*response.PaymailAddress{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-18T06:50:07.144902Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T06:50:07.144932Z"),
+ },
+ ID: "31b80181-4d8b-4766-9bc7-76a1d9c6b44d",
+ XpubID: "69245a3a-f9ed-4046-9acb-9d66c0b3750c",
+ Alias: "john.doe.test",
+ Domain: "john.doe.4chain.space",
+ PublicName: "John Doe",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-08T15:10:44.688653Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.561691Z"),
+ },
+ ID: "ec91273e-9fb7-4f10-9ecb-d1848d238814",
+ XpubID: "68026cb6-a549-45e8-97b1-11426bb16769",
+ Alias: "jane.doe.test",
+ Domain: "jane.doe.4chain.space",
+ PublicName: "Jane Doe",
+ Avatar: "http://localhost:3003/static/paymail/avatar.jpg",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 10,
+ Number: 1,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/admin/paymails/paymailstest/post_paymail_200.json b/internal/api/v1/admin/paymails/paymailstest/post_paymail_200.json
new file mode 100644
index 00000000..4560247f
--- /dev/null
+++ b/internal/api/v1/admin/paymails/paymailstest/post_paymail_200.json
@@ -0,0 +1,12 @@
+{
+ "createdAt": "2024-12-02T10:22:45.263654Z",
+ "updatedAt": "2024-12-02T11:22:45.263664+01:00",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "069d0011-580e-4fc6-9f24-45471b732a8b",
+ "xpubId": "22e6cba6-ef6e-432a-8612-63ac4b290ce9",
+ "alias": "john.doe.test",
+ "domain": "example.com",
+ "publicName": "john.doe.test",
+ "avatar": ""
+ }
diff --git a/internal/api/v1/admin/stats/stats_api.go b/internal/api/v1/admin/stats/stats_api.go
new file mode 100644
index 00000000..6145f1d1
--- /dev/null
+++ b/internal/api/v1/admin/stats/stats_api.go
@@ -0,0 +1,38 @@
+package stats
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "/v1/admin/stats"
+ api = "Admin Stats API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) Stats(ctx context.Context) (*models.AdminStats, error) {
+ var result models.AdminStats
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/stats/stats_api_test.go b/internal/api/v1/admin/stats/stats_api_test.go
new file mode 100644
index 00000000..67e860fc
--- /dev/null
+++ b/internal/api/v1/admin/stats/stats_api_test.go
@@ -0,0 +1,55 @@
+package stats_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/stats/statstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const statsURL = "/v1/admin/stats"
+
+func TestStatsAPI_Stats(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *models.AdminStats
+ expectedErr error
+ }{
+ "HTTP GET /v1/admin/stats response: 200": {
+ expectedResponse: statstest.ExpectedStatsResponse(),
+ responder: testutils.NewJSONFileResponderWithStatusOK("statstest/get_stats_200.json"),
+ },
+ "HTTP GET /v1/admin/stats response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /v1/admin/stats response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /v1/admin/stats str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, statsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // then:
+ got, err := wallet.Stats(context.Background())
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/stats/statstest/get_stats_200.json b/internal/api/v1/admin/stats/statstest/get_stats_200.json
new file mode 100644
index 00000000..ba41c526
--- /dev/null
+++ b/internal/api/v1/admin/stats/statstest/get_stats_200.json
@@ -0,0 +1,21 @@
+{
+ "balance": 0,
+ "destinations": 95,
+ "paymail_addresses": 20,
+ "transactions": 38,
+ "transactions_per_day": {
+ "20241003": 6,
+ "20241007": 3,
+ "20241107": 3,
+ "20241108": 5,
+ "20241111": 3,
+ "20241112": 10,
+ "20241118": 7,
+ "20241203": 1
+ },
+ "utxos": 54,
+ "utxos_per_type": {
+ "pubkeyhash": 54
+ },
+ "xpubs": 78
+ }
diff --git a/internal/api/v1/admin/stats/statstest/stats_api_fixtures.go b/internal/api/v1/admin/stats/statstest/stats_api_fixtures.go
new file mode 100644
index 00000000..579aa407
--- /dev/null
+++ b/internal/api/v1/admin/stats/statstest/stats_api_fixtures.go
@@ -0,0 +1,25 @@
+package statstest
+
+import "github.com/bitcoin-sv/spv-wallet/models"
+
+func ExpectedStatsResponse() *models.AdminStats {
+ return &models.AdminStats{
+ Balance: 0,
+ Destinations: 95,
+ PaymailAddresses: 20,
+ Transactions: 38,
+ TransactionsPerDay: map[string]any{
+ "20241003": float64(6),
+ "20241007": float64(3),
+ "20241107": float64(3),
+ "20241108": float64(5),
+ "20241111": float64(3),
+ "20241112": float64(10),
+ "20241118": float64(7),
+ "20241203": float64(1),
+ },
+ Utxos: 54,
+ UtxosPerType: map[string]any{"pubkeyhash": float64(54)},
+ XPubs: 78,
+ }
+}
diff --git a/internal/api/v1/admin/status/status_api.go b/internal/api/v1/admin/status/status_api.go
new file mode 100644
index 00000000..9d249f63
--- /dev/null
+++ b/internal/api/v1/admin/status/status_api.go
@@ -0,0 +1,39 @@
+package status
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "v1/admin/status"
+ api = "Admin Status API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) Status(ctx context.Context) (bool, error) {
+ res, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Get(a.url.String())
+ if err != nil {
+ if res.StatusCode() == http.StatusUnauthorized {
+ return false, nil
+ }
+ return false, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return true, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/status/status_api_test.go b/internal/api/v1/admin/status/status_api_test.go
new file mode 100644
index 00000000..fe388af5
--- /dev/null
+++ b/internal/api/v1/admin/status/status_api_test.go
@@ -0,0 +1,57 @@
+package status_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const statusURL = "/v1/admin/status"
+
+func TestStatusAPI_Status(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse bool
+ expectedErr error
+ }{
+ "HTTP GET /v1/admin/status response: 200": {
+ expectedResponse: true,
+ responder: testutils.NewStringResponderStatusOK("true"),
+ },
+ "HTTP GET /v1/admin/status response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /v1/admin/status response: 401": {
+ expectedResponse: false,
+ responder: testutils.NewUnauthorizedAccessSPVErrorResponder(),
+ },
+ "HTTP GET /v1/admin/status response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /v1/admin/status str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, statusURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // then:
+ got, err := wallet.Status(context.Background())
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/transactions/transactions_api.go b/internal/api/v1/admin/transactions/transactions_api.go
new file mode 100644
index 00000000..7ce4ca0a
--- /dev/null
+++ b/internal/api/v1/admin/transactions/transactions_api.go
@@ -0,0 +1,66 @@
+package transactions
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/admin/transactions"
+ api = "Admin Transactions API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) Transaction(ctx context.Context, ID string) (*response.Transaction, error) {
+ var result response.Transaction
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath(ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Transactions(ctx context.Context, opts ...queries.QueryOption[filter.AdminTransactionFilter]) (*queries.TransactionPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transactions query params: %w", err)
+ }
+
+ var result response.PageModel[response.Transaction]
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/transactions/transactions_api_test.go b/internal/api/v1/admin/transactions/transactions_api_test.go
new file mode 100644
index 00000000..9df4e5c1
--- /dev/null
+++ b/internal/api/v1/admin/transactions/transactions_api_test.go
@@ -0,0 +1,120 @@
+package transactions_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/transactions/transactionstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ transactionsURL = "/api/v1/admin/transactions"
+ id = "1024"
+)
+
+func TestTransactionsAPI_Transaction(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Transaction
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP GET /api/v1/admin/transactions/%s response: 200", id): {
+ expectedResponse: transactionstest.ExpectedTransaction(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/get_transaction_200.json"),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/transactions/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/transactions/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/admin/transactions/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.Transaction(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestTransactionsAPI_Transactions(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.PageModel[response.Transaction]
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/transactions response: 200": {
+ expectedResponse: transactionstest.ExpectedTransactionsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/get_transactions_200.json"),
+ },
+ "HTTP GET /api/v1/admin/transactions response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/transactions response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/transactions str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AdminTransactionFilter]{
+ queries.QueryWithPageFilter[filter.AdminTransactionFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AdminTransactionFilter{
+ TransactionFilter: filter.TransactionFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Transactions(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/transactions/transactionstest/get_transaction_200.json b/internal/api/v1/admin/transactions/transactionstest/get_transaction_200.json
new file mode 100644
index 00000000..71d4e529
--- /dev/null
+++ b/internal/api/v1/admin/transactions/transactionstest/get_transaction_200.json
@@ -0,0 +1,31 @@
+{
+ "createdAt": "2024-10-02T10:34:57.931744Z",
+ "updatedAt": "2024-10-18T13:59:02.237607Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": {
+ "pubkey": "c110ad13-9ded-4df3-a7af-99215e80a609",
+ "sender": "john.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "802f85de-23fa-40e3-adc2-165f33b9c853",
+ "user_agent": "node-fetch"
+ },
+ "id": "7efb8617-deb9-43cf-90df-d78782d40ab2",
+ "hex": "7198c88c-b695-4a19-b4af-6d912f87bf29",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "df5b2ccc-04cc-4ae3-8e8b-d311160eba75"
+ ],
+ "blockHash": "d97337d6-d735-4b41-a118-70a8813e616d",
+ "blockHeight": 864633,
+ "fee": 0,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 631,
+ "status": "MINED",
+ "direction": "outgoing"
+}
diff --git a/internal/api/v1/admin/transactions/transactionstest/get_transactions_200.json b/internal/api/v1/admin/transactions/transactionstest/get_transactions_200.json
new file mode 100644
index 00000000..fe46aa09
--- /dev/null
+++ b/internal/api/v1/admin/transactions/transactionstest/get_transactions_200.json
@@ -0,0 +1,75 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-18T07:19:51.661646Z",
+ "updatedAt": "2024-11-18T08:24:08.217141Z",
+ "deletedAt": null,
+ "metadata": {
+ "receiver": "john.doe.test@john.test.4chain.space",
+ "sender": "jane.doe.test@jane.test.4chain.space"
+ },
+ "id": "8b17fb99-bb11-4cdd-b04c-2d35c3d5070f",
+ "hex": "ae3d28fe-2610-496d-95ea-fbfa50dd571d",
+ "xpubInIds": [
+ "70dbd5a2-ab71-4a62-8240-ca5960d1327b"
+ ],
+ "xpubOutIds": [
+ "f3d9355f-a152-4075-be05-52daa98cc87c"
+ ],
+ "blockHash": "8bcc3e0b-3a0f-44a2-a32e-7fa40fe50db5",
+ "blockHeight": 871343,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 1,
+ "draftId": "c78a5ced-d3b0-4acf-bfff-7f4268760b0f",
+ "totalValue": 1,
+ "outputs": {
+ "ba121a0d-af03-41cb-bfb2-592005f73e55": 1,
+ "79c17f30-0580-48e1-8491-f147c869b73b": -2
+ },
+ "status": "",
+ "direction": ""
+ },
+ {
+ "createdAt": "2024-11-18T07:16:04.821925Z",
+ "updatedAt": "2024-11-19T13:09:39.501356Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": {
+ "note": "example note",
+ "pubkey": "bd568915-e532-4466-a0ab-70c7dead4b4b",
+ "sender": "jane.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "c7056aa68fb3586c74d0f8f7cb0ae52b",
+ "user_agent": "node-fetch"
+ },
+ "id": "2f130e50-3d0b-46b1-a1e6-ad866d60c2d2",
+ "hex": "8741462f-50ac-4fdc-bb22-b1db155899dc",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "1f1724bb-b167-4ac2-b97b-7a8e03a111c8"
+ ],
+ "blockHash": "fca9446e-5c56-44eb-930f-275ce0594f24",
+ "blockHeight": 871343,
+ "fee": 0,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 513,
+ "outputs": {
+ "e95ab632-2a96-466a-9676-5e86bc8a8d8d": 100
+ },
+ "status": "",
+ "direction": ""
+ }
+ ],
+ "page": {
+ "size": 2,
+ "number": 2,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/transactions/transactionstest/transactions_api_fixtrues.go b/internal/api/v1/admin/transactions/transactionstest/transactions_api_fixtrues.go
new file mode 100644
index 00000000..4ac7de59
--- /dev/null
+++ b/internal/api/v1/admin/transactions/transactionstest/transactions_api_fixtrues.go
@@ -0,0 +1,106 @@
+package transactionstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedTransaction(t *testing.T) *response.Transaction {
+ return &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-02T10:34:57.931744Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-18T13:59:02.237607Z"),
+ Metadata: map[string]any{
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "c110ad13-9ded-4df3-a7af-99215e80a609",
+ "sender": "john.doe@handcash.io",
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "802f85de-23fa-40e3-adc2-165f33b9c853",
+ "user_agent": "node-fetch",
+ },
+ },
+ ID: "7efb8617-deb9-43cf-90df-d78782d40ab2",
+ Hex: "7198c88c-b695-4a19-b4af-6d912f87bf29",
+ XpubOutIDs: []string{"df5b2ccc-04cc-4ae3-8e8b-d311160eba75"},
+ BlockHash: "d97337d6-d735-4b41-a118-70a8813e616d",
+ BlockHeight: 864633,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 631,
+ Status: "MINED",
+ TransactionDirection: "outgoing",
+ }
+}
+
+func ExpectedTransactionsPage(t *testing.T) *response.PageModel[response.Transaction] {
+ return &response.PageModel[response.Transaction]{
+ Content: []*response.Transaction{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.661646Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T08:24:08.217141Z"),
+ Metadata: map[string]any{
+ "receiver": "john.doe.test@john.test.4chain.space",
+ "sender": "jane.doe.test@jane.test.4chain.space",
+ },
+ },
+ ID: "8b17fb99-bb11-4cdd-b04c-2d35c3d5070f",
+ Hex: "ae3d28fe-2610-496d-95ea-fbfa50dd571d",
+ XpubOutIDs: []string{"f3d9355f-a152-4075-be05-52daa98cc87c"},
+ XpubInIDs: []string{"70dbd5a2-ab71-4a62-8240-ca5960d1327b"},
+ BlockHash: "8bcc3e0b-3a0f-44a2-a32e-7fa40fe50db5",
+ BlockHeight: 871343,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 1,
+ DraftID: "c78a5ced-d3b0-4acf-bfff-7f4268760b0f",
+ TotalValue: 1,
+ Outputs: map[string]int64{
+ "ba121a0d-af03-41cb-bfb2-592005f73e55": 1,
+ "79c17f30-0580-48e1-8491-f147c869b73b": -2,
+ },
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-18T07:16:04.821925Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-19T13:09:39.501356Z"),
+ Metadata: map[string]any{
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": map[string]any{
+ "note": "example note",
+ "pubkey": "bd568915-e532-4466-a0ab-70c7dead4b4b",
+ "sender": "jane.doe@handcash.io",
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "c7056aa68fb3586c74d0f8f7cb0ae52b",
+ "user_agent": "node-fetch",
+ },
+ },
+ ID: "2f130e50-3d0b-46b1-a1e6-ad866d60c2d2",
+ Hex: "8741462f-50ac-4fdc-bb22-b1db155899dc",
+ XpubOutIDs: []string{"1f1724bb-b167-4ac2-b97b-7a8e03a111c8"},
+ BlockHash: "fca9446e-5c56-44eb-930f-275ce0594f24",
+ BlockHeight: 871343,
+ Fee: 0,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 513,
+ Outputs: map[string]int64{
+ "e95ab632-2a96-466a-9676-5e86bc8a8d8d": 100,
+ },
+ },
+ },
+ Page: response.PageDescription{
+ Size: 2,
+ Number: 2,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/admin/utxos/utxos_api.go b/internal/api/v1/admin/utxos/utxos_api.go
new file mode 100644
index 00000000..5d9161a9
--- /dev/null
+++ b/internal/api/v1/admin/utxos/utxos_api.go
@@ -0,0 +1,52 @@
+package utxos
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "/api/v1/admin/utxos"
+ api = "Admin UTXOs API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) UTXOs(ctx context.Context, opts ...queries.QueryOption[filter.AdminUtxoFilter]) (*queries.UtxosPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build utxos query params: %w", err)
+ }
+
+ var result queries.UtxosPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetQueryParams(params.ParseToMap()).
+ SetResult(&result).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/utxos/utxos_api_test.go b/internal/api/v1/admin/utxos/utxos_api_test.go
new file mode 100644
index 00000000..9a098ec7
--- /dev/null
+++ b/internal/api/v1/admin/utxos/utxos_api_test.go
@@ -0,0 +1,74 @@
+package utxos_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/utxos/utxostest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const utxosURL = "/api/v1/admin/utxos"
+
+func TestUtxosAPI_UTXOs(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.UtxosPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/utxos response: 200": {
+ expectedResponse: utxostest.ExpectedUtxosPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("utxostest/get_utxos_200.json"),
+ },
+ "HTTP GET /api/v1/admin/utxos response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/utxos response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/utxos str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, utxosURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AdminUtxoFilter]{
+ queries.QueryWithPageFilter[filter.AdminUtxoFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AdminUtxoFilter{
+ UtxoFilter: filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.UTXOs(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/utxos/utxostest/get_utxos_200.json b/internal/api/v1/admin/utxos/utxostest/get_utxos_200.json
new file mode 100644
index 00000000..e0d2bc9c
--- /dev/null
+++ b/internal/api/v1/admin/utxos/utxostest/get_utxos_200.json
@@ -0,0 +1,52 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-18T07:19:51.661656Z",
+ "updatedAt": "2024-11-18T07:19:51.663878Z",
+ "deletedAt": null,
+ "metadata": null,
+ "transactionId": "ba371529-9746-4912-b9e4-4b3dc0539a40",
+ "outputIndex": 0,
+ "id": "21e69deb-8ea9-451b-9e1c-e89086ae439e",
+ "xpubId": "2054a737-18d1-4e6c-9d3e-370a68ffe7f0",
+ "satoshis": 1,
+ "scriptPubKey": "2c2de44c-acf8-4507-9cdb-cf9cf5273253",
+ "type": "pubkeyhash",
+ "draftId": "",
+ "reservedAt": "0001-01-01T00:00:00Z",
+ "spendingTxId": "",
+ "transaction": {
+ "createdAt": "2024-11-18T07:19:51.661646Z",
+ "updatedAt": "2024-11-18T08:24:08.217141Z",
+ "deletedAt": null,
+ "metadata": {
+ "receiver": "john.doe.test@test.4chain.space",
+ "sender": "john.doe.test@test.4chain.space"
+ },
+ "id": "d4fb1106-f023-43ce-9924-1d6f94bd5fbc",
+ "hex": "cc75acd2-66b5-4970-9965-6cd621cd40cd",
+ "xpubInIds": [
+ "9ecc1e7a-e122-41bd-9949-79ae8811de05"
+ ],
+ "xpubOutIds": [
+ "227bcc1e-95cb-4113-9d56-d583857fdf86"
+ ],
+ "blockHash": "f3063295-7caf-4b11-8361-193b01a302c1",
+ "blockHeight": 871343,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 1,
+ "draftId": "6ea5d19a-f57f-42fb-94a4-287a627e76ce",
+ "totalValue": 1,
+ "status": "MINED",
+ "direction": "outgoing"
+ }
+ }
+ ],
+ "page": {
+ "size": 1,
+ "number": 1,
+ "totalElements": 1,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/utxos/utxostest/utxos_api_fixtures.go b/internal/api/v1/admin/utxos/utxostest/utxos_api_fixtures.go
new file mode 100644
index 00000000..957ff8f0
--- /dev/null
+++ b/internal/api/v1/admin/utxos/utxostest/utxos_api_fixtures.go
@@ -0,0 +1,58 @@
+package utxostest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedUtxosPage(t *testing.T) *queries.UtxosPage {
+ return &queries.UtxosPage{
+ Content: []*response.Utxo{
+ {
+ Model: response.Model{CreatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.661656Z"), UpdatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.663878Z")},
+ UtxoPointer: response.UtxoPointer{TransactionID: "ba371529-9746-4912-b9e4-4b3dc0539a40"},
+ ID: "21e69deb-8ea9-451b-9e1c-e89086ae439e",
+ XpubID: "2054a737-18d1-4e6c-9d3e-370a68ffe7f0",
+ Satoshis: 1,
+ ScriptPubKey: "2c2de44c-acf8-4507-9cdb-cf9cf5273253",
+ Type: "pubkeyhash",
+ DraftID: "",
+ ReservedAt: testutils.ParseTime(t, "0001-01-01T00:00:00Z"),
+ SpendingTxID: "",
+ Transaction: &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.661646Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T08:24:08.217141Z"),
+
+ Metadata: map[string]any{
+ "receiver": "john.doe.test@test.4chain.space",
+ "sender": "john.doe.test@test.4chain.space",
+ },
+ },
+ ID: "d4fb1106-f023-43ce-9924-1d6f94bd5fbc",
+ Hex: "cc75acd2-66b5-4970-9965-6cd621cd40cd",
+ XpubInIDs: []string{"9ecc1e7a-e122-41bd-9949-79ae8811de05"},
+ XpubOutIDs: []string{"227bcc1e-95cb-4113-9d56-d583857fdf86"},
+ BlockHash: "f3063295-7caf-4b11-8361-193b01a302c1",
+ BlockHeight: 871343,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 1,
+ DraftID: "6ea5d19a-f57f-42fb-94a4-287a627e76ce",
+ TotalValue: 1,
+ Status: "MINED",
+ TransactionDirection: "outgoing",
+ },
+ },
+ },
+ Page: response.PageDescription{
+ Size: 1,
+ Number: 1,
+ TotalElements: 1,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/admin/webhooks/webhooks_api.go b/internal/api/v1/admin/webhooks/webhooks_api.go
new file mode 100644
index 00000000..b412a715
--- /dev/null
+++ b/internal/api/v1/admin/webhooks/webhooks_api.go
@@ -0,0 +1,50 @@
+package webhooks
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "/api/v1/admin/webhooks/subscriptions"
+ api = "Admin Webhooks API"
+)
+
+type API struct {
+ httpClient *resty.Client
+ url *url.URL
+}
+
+func (a *API) SubscribeWebhook(ctx context.Context, cmd *commands.CreateWebhookSubscription) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetBody(cmd).
+ Post(a.url.String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) UnsubscribeWebhook(ctx context.Context, cmd *commands.CancelWebhookSubscription) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetBody(cmd).
+ Delete(a.url.String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{url: url.JoinPath(route), httpClient: httpClient}
+}
diff --git a/internal/api/v1/admin/webhooks/webhooks_api_test.go b/internal/api/v1/admin/webhooks/webhooks_api_test.go
new file mode 100644
index 00000000..06c769ac
--- /dev/null
+++ b/internal/api/v1/admin/webhooks/webhooks_api_test.go
@@ -0,0 +1,96 @@
+package webhooks_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ webhooksURL = "/api/v1/admin/webhooks/subscriptions"
+ url = "http://webhook1.com"
+)
+
+func TestWebhooksAPI_SubscribeWebhook(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/admin/webhooks/subscriptions response: 200": {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ "HTTP POST /api/v1/admin/webhooks/subscriptions response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/webhooks/subscriptions response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/webhooks/subscriptions str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, webhooksURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // then:
+ err := wallet.SubscribeWebhook(context.Background(), &commands.CreateWebhookSubscription{
+ URL: url,
+ TokenHeader: "Header",
+ TokenValue: "76dd388f-62de-4957-afae-967c3a424bc7",
+ })
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestWebhooksAPI_UnsubscribeWebhook(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ "HTTP DELETE /api/v1/admin/webhooks/subscriptions response: 200": {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ "HTTP DELETE /api/v1/admin/webhooks/subscriptions response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP DELETE /api/v1/admin/webhooks/subscriptions response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP DELETE /api/v1/admin/webhooks/subscriptions str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, webhooksURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // then:
+ err := wallet.UnsubscribeWebhook(context.Background(), &commands.CancelWebhookSubscription{
+ URL: url,
+ })
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/xpubs/xpubs_api.go b/internal/api/v1/admin/xpubs/xpubs_api.go
new file mode 100644
index 00000000..54ea2b87
--- /dev/null
+++ b/internal/api/v1/admin/xpubs/xpubs_api.go
@@ -0,0 +1,70 @@
+package xpubs
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/admin/users"
+ api = "Admin XPub API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) CreateXPub(ctx context.Context, cmd *commands.CreateUserXpub) (*response.Xpub, error) {
+ var result response.Xpub
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(cmd).
+ Post(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) XPubs(ctx context.Context, opts ...queries.QueryOption[filter.XpubFilter]) (*queries.XPubPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build user xpubs query params: %w", err)
+ }
+
+ var result queries.XPubPage
+ _, err = a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/admin/xpubs/xpubs_api_test.go b/internal/api/v1/admin/xpubs/xpubs_api_test.go
new file mode 100644
index 00000000..420155a1
--- /dev/null
+++ b/internal/api/v1/admin/xpubs/xpubs_api_test.go
@@ -0,0 +1,118 @@
+package xpubs_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/xpubs/xpubstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const xpubsURL = "/api/v1/admin/users"
+
+func TestXPubsAPI_CreateXPub(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Xpub
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/admin/users response: 201": {
+ expectedResponse: xpubstest.ExpectedXPub(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("xpubstest/post_xpub_201.json"),
+ },
+ "HTTP POST /api/v1/admin/users response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/users response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/admin/users str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, xpubsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ got, err := wallet.CreateXPub(context.Background(), &commands.CreateUserXpub{
+ Metadata: map[string]any{},
+ XPub: "",
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestXPubsAPI_XPubs(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.XPubPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/admin/users response: 200": {
+ expectedResponse: xpubstest.ExpectedXPubsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("xpubstest/get_xpubs_200.json"),
+ },
+ "HTTP GET /api/v1/admin/users response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/users response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/admin/users str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, xpubsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.XpubFilter]{
+ queries.QueryWithPageFilter[filter.XpubFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.XpubFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVAdminAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.XPubs(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/admin/xpubs/xpubstest/get_xpubs_200.json b/internal/api/v1/admin/xpubs/xpubstest/get_xpubs_200.json
new file mode 100644
index 00000000..ff1602e7
--- /dev/null
+++ b/internal/api/v1/admin/xpubs/xpubstest/get_xpubs_200.json
@@ -0,0 +1,34 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-21T11:41:49.830635Z",
+ "updatedAt": "2024-11-21T11:41:49.830649Z",
+ "deletedAt": null,
+ "metadata": {
+ "key": "val"
+ },
+ "id": "3c7a9d02-32e3-4d83-a391-af64f1933acb",
+ "currentBalance": 10,
+ "nextInternalNum": 20,
+ "nextExternalNum": 30
+ },
+ {
+ "createdAt": "2024-11-21T11:26:43.091808Z",
+ "updatedAt": "2024-11-21T11:26:43.091857Z",
+ "deletedAt": null,
+ "metadata": {
+ "key": "val"
+ },
+ "id": "301f38e2-f1dc-43cb-9db2-f2835a648b8b",
+ "currentBalance": 40,
+ "nextInternalNum": 50,
+ "nextExternalNum": 60
+ }
+ ],
+ "page": {
+ "size": 50,
+ "number": 1,
+ "totalElements": 40,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/admin/xpubs/xpubstest/post_xpub_201.json b/internal/api/v1/admin/xpubs/xpubstest/post_xpub_201.json
new file mode 100644
index 00000000..cbbcb84a
--- /dev/null
+++ b/internal/api/v1/admin/xpubs/xpubstest/post_xpub_201.json
@@ -0,0 +1,12 @@
+{
+ "createdAt": "2024-11-22T07:51:37.708754Z",
+ "updatedAt": "2024-11-22T08:51:37.708865+01:00",
+ "deletedAt": null,
+ "metadata": {
+ "key": "value"
+ },
+ "id": "d7ff33b6-8c25-4955-bcea-a5557c18bb95",
+ "currentBalance": 0,
+ "nextInternalNum": 0,
+ "nextExternalNum": 0
+ }
diff --git a/internal/api/v1/admin/xpubs/xpubstest/xpub_api_fixtures.go b/internal/api/v1/admin/xpubs/xpubstest/xpub_api_fixtures.go
new file mode 100644
index 00000000..731b5161
--- /dev/null
+++ b/internal/api/v1/admin/xpubs/xpubstest/xpub_api_fixtures.go
@@ -0,0 +1,58 @@
+package xpubstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedXPub(t *testing.T) *response.Xpub {
+ return &response.Xpub{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-22T07:51:37.708754Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-22T08:51:37.708865+01:00"),
+ Metadata: map[string]any{"key": "value"},
+ },
+ ID: "d7ff33b6-8c25-4955-bcea-a5557c18bb95",
+ CurrentBalance: 0,
+ NextInternalNum: 0,
+ NextExternalNum: 0,
+ }
+}
+
+func ExpectedXPubsPage(t *testing.T) *queries.XPubPage {
+ return &queries.XPubPage{
+ Content: []*response.Xpub{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-21T11:41:49.830635Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-21T11:41:49.830649Z"),
+ Metadata: map[string]any{"key": "val"},
+ },
+ ID: "3c7a9d02-32e3-4d83-a391-af64f1933acb",
+ CurrentBalance: 10,
+ NextInternalNum: 20,
+ NextExternalNum: 30,
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-21T11:26:43.091808Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-21T11:26:43.091857Z"),
+ Metadata: map[string]any{"key": "val"},
+ },
+ ID: "301f38e2-f1dc-43cb-9db2-f2835a648b8b",
+ CurrentBalance: 40,
+ NextInternalNum: 50,
+ NextExternalNum: 60,
+ },
+ },
+ Page: response.PageDescription{
+ Size: 50,
+ Number: 1,
+ TotalElements: 40,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/configs/configs_api.go b/internal/api/v1/configs/configs_api.go
new file mode 100644
index 00000000..a3c5971b
--- /dev/null
+++ b/internal/api/v1/configs/configs_api.go
@@ -0,0 +1,41 @@
+package configs
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/configs"
+ api = "Shared Config API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) {
+ var result response.SharedConfig
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath("shared").String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/configs/configs_api_test.go b/internal/api/v1/configs/configs_api_test.go
new file mode 100644
index 00000000..0f66d1cc
--- /dev/null
+++ b/internal/api/v1/configs/configs_api_test.go
@@ -0,0 +1,62 @@
+package configs_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const configsURL = "/api/v1/configs/shared"
+
+func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) {
+ tests := map[string]struct {
+ expectedResponse *response.SharedConfig
+ expectedErr error
+ responder httpmock.Responder
+ }{
+ "HTTP GET /api/v1/configs/shared response: 200": {
+ expectedResponse: &response.SharedConfig{
+ PaymailDomains: []string{"john.test.4chain.space"},
+ ExperimentalFeatures: map[string]bool{
+ "pikeContactsEnabled": true,
+ "pikePaymentEnabled": true,
+ },
+ },
+ responder: testutils.NewJSONFileResponderWithStatusOK("configstest/response_200_status_code.json"),
+ },
+ "HTTP GET /api/v1/configs/shared response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/configs/shared response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/configs/shared str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, configsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.SharedConfig(context.Background())
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/configs/configstest/response_200_status_code.json b/internal/api/v1/configs/configstest/response_200_status_code.json
new file mode 100644
index 00000000..56b93eb5
--- /dev/null
+++ b/internal/api/v1/configs/configstest/response_200_status_code.json
@@ -0,0 +1,9 @@
+{
+ "paymailDomains": [
+ "john.test.4chain.space"
+ ],
+ "experimentalFeatures": {
+ "pikeContactsEnabled": true,
+ "pikePaymentEnabled": true
+ }
+}
diff --git a/internal/api/v1/errutil/errutil.go b/internal/api/v1/errutil/errutil.go
new file mode 100644
index 00000000..f08501ed
--- /dev/null
+++ b/internal/api/v1/errutil/errutil.go
@@ -0,0 +1,43 @@
+package errutil
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// HTTPErrorFormatter is a utility struct that formats HTTP errors.
+type HTTPErrorFormatter struct {
+ Action string
+ API string
+ Err error
+}
+
+// Format creates a formatted error message for HTTP requests.
+func (h HTTPErrorFormatter) Format(method string) error {
+ return fmt.Errorf("failed to send HTTP %s request to %s via %s: %w", method, h.Action, h.API, h.Err)
+}
+
+// FormatPutErr is a convenience method for formatting HTTP PUT errors.
+func (h HTTPErrorFormatter) FormatPutErr() error { return h.Format(http.MethodPut) }
+
+// FormatPatchErr is a convenience method for formatting HTTP PATCH errors.
+func (h HTTPErrorFormatter) FormatPatchErr() error { return h.Format(http.MethodPatch) }
+
+// FormatPostErr is a convenience method for formatting HTTP POST errors.
+func (h HTTPErrorFormatter) FormatPostErr() error { return h.Format(http.MethodPost) }
+
+// FormatGetErr is a convenience method for formatting HTTP GET errors.
+func (h HTTPErrorFormatter) FormatGetErr() error { return h.Format(http.MethodGet) }
+
+// FormatDeleteErr is a convenience method for formatting HTTP DELETE errors.
+func (h HTTPErrorFormatter) FormatDeleteErr() error { return h.Format(http.MethodDelete) }
+
+// NewHTTPErrorFormatter creates a new instance of HTTPErrorFormatter.
+// It eliminates redundancy and ensures consistency across the codebase.
+func NewHTTPErrorFormatter(api string, action string, err error) *HTTPErrorFormatter {
+ return &HTTPErrorFormatter{
+ API: api,
+ Action: action,
+ Err: err,
+ }
+}
diff --git a/internal/api/v1/errutil/errutil_test.go b/internal/api/v1/errutil/errutil_test.go
new file mode 100644
index 00000000..1c80854b
--- /dev/null
+++ b/internal/api/v1/errutil/errutil_test.go
@@ -0,0 +1,33 @@
+package errutil_test
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHTTPErrorFormatter_Format(t *testing.T) {
+ // given:
+ const (
+ API = "Users API"
+ action = "retrieve users page"
+ )
+ wrappedErr := errors.New(http.StatusText(http.StatusInternalServerError))
+ expectedErr := fmt.Errorf("failed to send HTTP %s request to %s via %s: %w", http.MethodPost, action, API, wrappedErr)
+
+ formatter := errutil.HTTPErrorFormatter{
+ Action: action,
+ API: API,
+ Err: wrappedErr,
+ }
+
+ // when:
+ got := formatter.Format(http.MethodPost)
+
+ // then:
+ require.Equal(t, got, expectedErr)
+}
diff --git a/internal/api/v1/queryparams/metadata_parser.go b/internal/api/v1/queryparams/metadata_parser.go
new file mode 100644
index 00000000..2534365f
--- /dev/null
+++ b/internal/api/v1/queryparams/metadata_parser.go
@@ -0,0 +1,103 @@
+package queryparams
+
+import (
+ "fmt"
+ "net/url"
+ "reflect"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+)
+
+type Metadata map[string]any
+
+const DefaultMaxDepth = 100
+
+type MetadataParser struct {
+ MaxDepth int
+ Metadata Metadata
+}
+
+func (m *MetadataParser) Parse() (url.Values, error) {
+ params := make(url.Values)
+ for k, v := range m.Metadata {
+ path := newMetadataPath(k)
+ if err := m.generateQueryParams(0, path, v, params); err != nil {
+ return nil, err
+ }
+ }
+
+ return params, nil
+}
+
+func (m *MetadataParser) generateQueryParams(depth int, path metadataPath, val any, params url.Values) error {
+ if depth > m.MaxDepth {
+ return fmt.Errorf("%w - max depth: %d", errors.ErrMetadataFilterMaxDepthExceeded, m.MaxDepth)
+ }
+
+ if val == nil {
+ return nil
+ }
+
+ switch reflect.TypeOf(val).Kind() {
+ case reflect.Map:
+ return m.processMapQueryParams(depth+1, val, path, params)
+ case reflect.Slice:
+ return m.processSliceQueryParams(val, path, params)
+ default:
+ path.addToURL(params, val)
+ return nil
+ }
+}
+
+func (m *MetadataParser) processMapQueryParams(depth int, val any, param metadataPath, params url.Values) error {
+ rval := reflect.ValueOf(val)
+ for _, k := range rval.MapKeys() {
+ nested := param.nestPath(k.Interface())
+ if err := m.generateQueryParams(depth+1, nested, rval.MapIndex(k).Interface(), params); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (m *MetadataParser) processSliceQueryParams(val any, path metadataPath, params url.Values) error {
+ slice := reflect.ValueOf(val)
+ arr := make([]any, slice.Len())
+ for i := 0; i < slice.Len(); i++ {
+ item := slice.Index(i)
+
+ // safe check - only primitive types are allowed in arrays
+ // note: kind := item.Kind() is not enough, because it returns interface instead of actual underlying type
+ kind := reflect.TypeOf(item.Interface()).Kind()
+ if kind == reflect.Map || kind == reflect.Slice {
+ return errors.ErrMetadataWrongTypeInArray
+ }
+
+ arr[i] = item.Interface()
+ }
+ path.addArrayToURL(params, arr)
+
+ return nil
+}
+
+type metadataPath string
+
+func (m metadataPath) nestPath(key any) metadataPath {
+ return metadataPath(fmt.Sprintf("%s[%v]", m, key))
+}
+
+func (m metadataPath) addToURL(urlValues url.Values, value any) {
+ urlValues.Add(string(m), fmt.Sprintf("%v", value))
+}
+
+func (m metadataPath) addArrayToURL(urlValues url.Values, values []any) {
+ key := string(m) + "[]"
+ for _, value := range values {
+ urlValues.Add(key, fmt.Sprintf("%v", value))
+ }
+}
+
+func newMetadataPath(key string) metadataPath {
+ return metadataPath(fmt.Sprintf("metadata[%s]", key))
+}
diff --git a/internal/api/v1/queryparams/metadata_parser_test.go b/internal/api/v1/queryparams/metadata_parser_test.go
new file mode 100644
index 00000000..b0bd6a11
--- /dev/null
+++ b/internal/api/v1/queryparams/metadata_parser_test.go
@@ -0,0 +1,218 @@
+package queryparams_test
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMetadataParser_Parse(t *testing.T) {
+ tests := map[string]struct {
+ metadata queryparams.Metadata
+ expectedParams url.Values
+ expectedErr error
+ depth int
+ }{
+ "metadata: empty map": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: make(url.Values),
+ },
+ "metadata: map entry [key]=nil": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: make(url.Values),
+ metadata: queryparams.Metadata{
+ "key": nil,
+ },
+ },
+ "metadata: map entries [key1]=value1, [key2]=nil": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1][]": []string{"value1"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": []string{"value1"},
+ "key2": nil,
+ },
+ },
+ "metadata: map entry [key]=value1": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key]": []string{"value1"},
+ },
+ metadata: queryparams.Metadata{
+ "key": "value1",
+ },
+ },
+ "metadata: map entries [key1]=value1, [key2]=1024": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2]": []string{"1024"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": 1024,
+ },
+ },
+ "metadata: two keys nested in one": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1][key2]": []string{"value1"},
+ "metadata[key1][key3]": []string{"1024"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key2": "value1",
+ "key3": 1024,
+ },
+ },
+ },
+ "metadata: map entries [hey=123&522]=value1, [key2]=value=123": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[hey=123&522]": []string{"value1"},
+ "metadata[key2]": []string{"value=123"},
+ },
+ metadata: queryparams.Metadata{
+ "hey=123&522": "value1",
+ "key2": "value=123",
+ },
+ },
+ "metadata: map entries [key1]=value1, [key2]=[]{value2,value3,value4}": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ },
+ },
+ "metadata: map entries [key1]=value1, [key2]=[]{value2, value3, value4}, [key3]=value5, [key4]=[]{value6,value7,value8}": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3]": []string{"value5"},
+ "metadata[key4][]": []string{"value6", "value7", "value8"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": "value5",
+ "key4": []string{"value6", "value7", "value8"},
+ },
+ },
+ "metadata: map entries [key1]=value1, [key2]=[value1,value2,value3,value4], [key3][key3_nested]=value5, [key4][key4_nested]=[6, 7]": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ "metadata: 11 map entries, complex nesting, max depth set to 100": {
+ depth: queryparams.DefaultMaxDepth,
+ expectedParams: url.Values{
+ "metadata[key1][key2][key3][key1]": []string{"abc"},
+ "metadata[key1][key2][key3][key2][key1]": []string{"9"},
+ "metadata[key1][key2][key3][key3][key1][key2][key1][]": []string{"1", "2", "3", "4"},
+ "metadata[key1][key2][key3][key3][key1][key2][key2]": []string{"10"},
+ "metadata[key1][key2][key3][key3][key1][key2][key3]": []string{"abc"},
+ "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key1]": []string{"2"},
+ "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key2]": []string{"cde"},
+ "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key3][key1][]": []string{"5", "6", "7", "8"},
+ "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key3][key2][]": []string{"a", "b", "c"},
+ },
+ metadata: queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key2": queryparams.Metadata{
+ "key3": queryparams.Metadata{
+ "key1": "abc",
+ "key2": queryparams.Metadata{
+ "key1": 9,
+ },
+ "key3": queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key2": queryparams.Metadata{
+ "key1": []int{1, 2, 3, 4},
+ "key2": 10,
+ "key3": "abc",
+ "key4": queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key1": 2,
+ "key2": "cde",
+ "key3": queryparams.Metadata{
+ "key1": []int{5, 6, 7, 8},
+ "key2": []string{"a", "b", "c"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ "metadata: map entries depth exceeded - map entries: 4, max depth: 3": {
+ metadata: queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key2": queryparams.Metadata{
+ "key3": queryparams.Metadata{
+ "key4": "value1",
+ },
+ },
+ },
+ },
+ depth: 3,
+ expectedErr: errors.ErrMetadataFilterMaxDepthExceeded,
+ },
+ "metadata: unsupported map in array": {
+ metadata: queryparams.Metadata{
+ "key1": queryparams.Metadata{
+ "key2": []any{
+ queryparams.Metadata{
+ "key3": "value1",
+ },
+ },
+ },
+ },
+ depth: queryparams.DefaultMaxDepth,
+ expectedErr: errors.ErrMetadataWrongTypeInArray,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // when:
+ parser := queryparams.MetadataParser{
+ MaxDepth: tc.depth,
+ Metadata: tc.metadata,
+ }
+
+ // then:
+ got, err := parser.Parse()
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedParams, got)
+ })
+ }
+}
diff --git a/internal/api/v1/queryparams/query_parser.go b/internal/api/v1/queryparams/query_parser.go
new file mode 100644
index 00000000..3a2d37d9
--- /dev/null
+++ b/internal/api/v1/queryparams/query_parser.go
@@ -0,0 +1,102 @@
+package queryparams
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+)
+
+type QueryParser[F queries.QueryFilters] struct {
+ query *queries.Query[F]
+}
+
+func (q *QueryParser[F]) contaisModelFilter(t reflect.Type) bool {
+ // Check if the type directly matches ModelFilter
+ if t == reflect.TypeOf(filter.ModelFilter{}) {
+ return true
+ }
+ // If the input is a struct, check its fields for ModelFilter
+ if t.Kind() == reflect.Struct {
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.Type == reflect.TypeOf(filter.ModelFilter{}) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (q *QueryParser[F]) parse(val any, totalParams *URLValues) {
+ t := reflect.TypeOf(val)
+ v := reflect.ValueOf(val)
+
+ for i := 0; i < reflect.TypeOf(val).NumField(); i++ {
+ field := t.Field(i)
+ value := v.Field(i)
+
+ if q.contaisModelFilter(field.Type) {
+ q.parse(value.Interface(), totalParams)
+ continue
+ }
+
+ if value.Kind() == reflect.Ptr && value.IsNil() {
+ continue
+ }
+
+ tags := strings.Split(field.Tag.Get("json"), ",")
+ if len(tags) == 0 {
+ continue
+ }
+ tag := tags[0]
+
+ switch field.Type {
+ case reflect.PointerTo(reflect.TypeOf(filter.TimeRange{})):
+ totalParams.AddPair(tag, value.Interface().(*filter.TimeRange))
+
+ case reflect.PointerTo(reflect.TypeOf(false)):
+ totalParams.AddPair(tag, value.Elem().Bool())
+
+ case reflect.PointerTo(reflect.TypeOf("")):
+ totalParams.AddPair(tag, value.Elem().String())
+
+ case reflect.PointerTo(reflect.TypeOf(uint64(0))), reflect.PointerTo(reflect.TypeOf(uint32(0))):
+ totalParams.AddPair(tag, value.Elem().Uint())
+
+ default:
+ totalParams.AddPair(tag, fmt.Sprintf("%v", value.Interface()))
+ }
+ }
+}
+
+func (q *QueryParser[F]) Parse() (*URLValues, error) {
+ totalParams := NewURLValues()
+ // Parse page filter if present
+ if q.query.PageFilter != (filter.Page{}) {
+ q.parse(q.query.PageFilter, totalParams)
+ }
+
+ // Parse metadata
+ metadata := &MetadataParser{Metadata: q.query.Metadata, MaxDepth: DefaultMaxDepth}
+ params, err := metadata.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse metadata: %w", err)
+ }
+ totalParams.Append(params)
+
+ // Parse main query filter
+ q.parse(q.query.Filter, totalParams)
+ return totalParams, nil
+}
+
+func NewQueryParser[F queries.QueryFilters](query *queries.Query[F]) (*QueryParser[F], error) {
+ if query == nil {
+ return nil, goclienterr.ErrQueryParserFailed
+ }
+
+ return &QueryParser[F]{query: query}, nil
+}
diff --git a/internal/api/v1/queryparams/query_parser_test.go b/internal/api/v1/queryparams/query_parser_test.go
new file mode 100644
index 00000000..03b59925
--- /dev/null
+++ b/internal/api/v1/queryparams/query_parser_test.go
@@ -0,0 +1,1487 @@
+package queryparams_test
+
+import (
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/stretchr/testify/require"
+)
+
+func TestQueryParser_Parse_AdminUtxosQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.AdminUtxoFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "admin utxos query: with only metadata": {
+ query: &queries.Query[filter.AdminUtxoFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "admin utxos query: with only page filter": {
+ query: &queries.Query[filter.AdminUtxoFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "admin utxos query: with only model filter": {
+ query: &queries.Query[filter.AdminUtxoFilter]{
+ Filter: filter.AdminUtxoFilter{
+ UtxoFilter: filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "admin utxos query: all fields set": {
+ query: &queries.Query[filter.AdminUtxoFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.AdminUtxoFilter{
+ UtxoFilter: filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC))},
+ },
+ SpendingTxID: testutils.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"),
+ DraftID: testutils.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"),
+ Type: testutils.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"),
+ ScriptPubKey: testutils.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"),
+ ID: testutils.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"),
+ OutputIndex: testutils.Ptr(uint32(32)),
+ Satoshis: testutils.Ptr(uint64(64)),
+ TransactionID: testutils.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"),
+ ReservedRange: &filter.TimeRange{
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ XpubID: testutils.Ptr("c4dab549-902e-42fe-97d2-dc556b716f9a"),
+ },
+ },
+ expectedValues: url.Values{
+ "xpubId": []string{"c4dab549-902e-42fe-97d2-dc556b716f9a"},
+ "scriptPubKey": []string{"3adec124-32eb-46f1-94f2-4949a86dbe8d"},
+ "draftId": []string{"2453797c-4089-4078-8723-5ecb68e70bd7"},
+ "reservedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "reservedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "transactionId": []string{"124c2237-9b54-46c4-bf53-3cec86f7e316"},
+ "spendingTxId": []string{"7539366c-beb2-4405-8597-025bf2dc7cbd"},
+ "type": []string{"0f65e842-decf-4725-8ad9-877634280e4f"},
+ "satoshis": []string{"64"},
+ "id": []string{"abb6a871-dd95-4f7a-8090-ca34cff63801"},
+ "outputIndex": []string{"32"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_AdminTransactionsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.AdminTransactionFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "admin transactions query: with only metadata": {
+ query: &queries.Query[filter.AdminTransactionFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "admin transactions query: with only page filter": {
+ query: &queries.Query[filter.AdminTransactionFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "admin transactions query: with only model filter": {
+ query: &queries.Query[filter.AdminTransactionFilter]{
+ Filter: filter.AdminTransactionFilter{
+ TransactionFilter: filter.TransactionFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "admin transactions query: all fields set": {
+ query: &queries.Query[filter.AdminTransactionFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.AdminTransactionFilter{
+ TransactionFilter: filter.TransactionFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ Id: testutils.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"),
+ Hex: testutils.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"),
+ BlockHash: testutils.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"),
+ BlockHeight: testutils.Ptr(uint64(839376)),
+ Fee: testutils.Ptr(uint64(1)),
+ NumberOfInputs: testutils.Ptr(uint32(10)),
+ NumberOfOutputs: testutils.Ptr(uint32(20)),
+ DraftID: testutils.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"),
+ TotalValue: testutils.Ptr(uint64(100000000)),
+ Status: testutils.Ptr("RECEIVED"),
+ },
+ XPubID: testutils.Ptr("9b496655-616a-48cd-a3f8-89608473a5f1"),
+ },
+ },
+ expectedValues: url.Values{
+ "xpubId": []string{"9b496655-616a-48cd-a3f8-89608473a5f1"},
+ "id": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"},
+ "hex": []string{"001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"},
+ "blockHash": []string{"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"},
+ "blockHeight": []string{"839376"},
+ "fee": []string{"1"},
+ "numberOfInputs": []string{"10"},
+ "numberOfOutputs": []string{"20"},
+ "draftId": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"},
+ "totalValue": []string{"100000000"},
+ "status": []string{"RECEIVED"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_AdminPaymailsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.AdminPaymailFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "admin paymails query: with only metadata": {
+ query: &queries.Query[filter.AdminPaymailFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "admin paymails query: with only page filter": {
+ query: &queries.Query[filter.AdminPaymailFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "admin paymails query: with only model filter": {
+ query: &queries.Query[filter.AdminPaymailFilter]{
+ Filter: filter.AdminPaymailFilter{
+ PaymailFilter: filter.PaymailFilter{
+
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "admin paymails query: all fields set": {
+ query: &queries.Query[filter.AdminPaymailFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.AdminPaymailFilter{
+ PaymailFilter: filter.PaymailFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ ID: testutils.Ptr("b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"),
+ PublicName: testutils.Ptr("Alice"),
+ Alias: testutils.Ptr("alias"),
+ },
+ XpubID: testutils.Ptr("9b496655-616a-48cd-a3f8-89608473a5f1"),
+ },
+ },
+ expectedValues: url.Values{
+ "publicName": []string{"Alice"},
+ "alias": []string{"alias"},
+ "id": []string{"b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"},
+ "xpubId": []string{"9b496655-616a-48cd-a3f8-89608473a5f1"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_AdminContactsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.AdminContactFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "admin contacts query: with only metadata": {
+ query: &queries.Query[filter.AdminContactFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "admin contacts query: with only page filter": {
+ query: &queries.Query[filter.AdminContactFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "admin contacts query: with only model filter": {
+ query: &queries.Query[filter.AdminContactFilter]{
+ Filter: filter.AdminContactFilter{
+ ContactFilter: filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "admin contacts query: all fields set": {
+ query: &queries.Query[filter.AdminContactFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.AdminContactFilter{
+ ContactFilter: filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ ID: testutils.Ptr("b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"),
+ FullName: testutils.Ptr("John Doe"),
+ Paymail: testutils.Ptr("test@example.com"),
+ PubKey: testutils.Ptr("pubKey"),
+ Status: testutils.Ptr("confirmed"),
+ },
+ XPubID: testutils.Ptr("xpub6CUGRUonZSQ4TWtTMmzXdrXDtyPWKi"),
+ },
+ },
+ expectedValues: url.Values{
+ "id": []string{"b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"},
+ "fullName": []string{"John Doe"},
+ "paymail": []string{"test@example.com"},
+ "pubKey": []string{"pubKey"},
+ "status": []string{"confirmed"},
+ "xpubId": []string{"xpub6CUGRUonZSQ4TWtTMmzXdrXDtyPWKi"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_AdminAccessKeysQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.AdminAccessKeyFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "admin access keys query: with only metadata": {
+ query: &queries.Query[filter.AdminAccessKeyFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "admin access keys query: with only page filter": {
+ query: &queries.Query[filter.AdminAccessKeyFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "admin access keys query: with only model filter": {
+ query: &queries.Query[filter.AdminAccessKeyFilter]{
+ Filter: filter.AdminAccessKeyFilter{
+ AccessKeyFilter: filter.AccessKeyFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "admin access keys query: all fields set": {
+ query: &queries.Query[filter.AdminAccessKeyFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.AdminAccessKeyFilter{
+ XpubID: testutils.Ptr("9b496655-616a-48cd-a3f8-89608473a5f1"),
+ AccessKeyFilter: filter.AccessKeyFilter{
+ RevokedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "xpubId": []string{"9b496655-616a-48cd-a3f8-89608473a5f1"},
+ "revokedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "revokedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_XpubsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.XpubFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "xpubs query: with only metadata": {
+ query: &queries.Query[filter.XpubFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "xpubs query: with only page filter": {
+ query: &queries.Query[filter.XpubFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "xpubs query: with only model filter": {
+ query: &queries.Query[filter.XpubFilter]{
+ Filter: filter.XpubFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "xpubs query: all fields set": {
+ query: &queries.Query[filter.XpubFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.XpubFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ ID: testutils.Ptr("5505cbc3-b38f-40d4-885f-c53efd84828f"),
+ CurrentBalance: testutils.Ptr(uint64(24)),
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "id": []string{"5505cbc3-b38f-40d4-885f-c53efd84828f"},
+ "currentBalance": []string{"24"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_UtxosQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.UtxoFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "utxos query: with only metadata": {
+ query: &queries.Query[filter.UtxoFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "utxos query: with only page filter": {
+ query: &queries.Query[filter.UtxoFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "utxos query: with only model filter": {
+ query: &queries.Query[filter.UtxoFilter]{
+ Filter: filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "utxos query: all fields set": {
+ query: &queries.Query[filter.UtxoFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ SpendingTxID: testutils.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"),
+ DraftID: testutils.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"),
+ Type: testutils.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"),
+ ScriptPubKey: testutils.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"),
+ ID: testutils.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"),
+ OutputIndex: testutils.Ptr(uint32(32)),
+ Satoshis: testutils.Ptr(uint64(64)),
+ TransactionID: testutils.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"),
+ ReservedRange: &filter.TimeRange{
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "scriptPubKey": []string{"3adec124-32eb-46f1-94f2-4949a86dbe8d"},
+ "draftId": []string{"2453797c-4089-4078-8723-5ecb68e70bd7"},
+ "reservedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "reservedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "transactionId": []string{"124c2237-9b54-46c4-bf53-3cec86f7e316"},
+ "spendingTxId": []string{"7539366c-beb2-4405-8597-025bf2dc7cbd"},
+ "type": []string{"0f65e842-decf-4725-8ad9-877634280e4f"},
+ "satoshis": []string{"64"},
+ "id": []string{"abb6a871-dd95-4f7a-8090-ca34cff63801"},
+ "outputIndex": []string{"32"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_TransactionsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.TransactionFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "transactions query: with only metadata": {
+ query: &queries.Query[filter.TransactionFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "transactions query: with only page filter": {
+ query: &queries.Query[filter.TransactionFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "transactions query: with only model filter": {
+ query: &queries.Query[filter.TransactionFilter]{
+ Filter: filter.TransactionFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "transactions query: all fields set": {
+ query: &queries.Query[filter.TransactionFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.TransactionFilter{
+ Id: testutils.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"),
+ Hex: testutils.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"),
+ BlockHash: testutils.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"),
+ BlockHeight: testutils.Ptr(uint64(839376)),
+ Fee: testutils.Ptr(uint64(1)),
+ NumberOfInputs: testutils.Ptr(uint32(10)),
+ NumberOfOutputs: testutils.Ptr(uint32(20)),
+ DraftID: testutils.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"),
+ TotalValue: testutils.Ptr(uint64(100000000)),
+ Status: testutils.Ptr("RECEIVED"),
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "id": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"},
+ "hex": []string{"001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"},
+ "blockHash": []string{"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"},
+ "blockHeight": []string{"839376"},
+ "fee": []string{"1"},
+ "numberOfInputs": []string{"10"},
+ "numberOfOutputs": []string{"20"},
+ "draftId": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"},
+ "totalValue": []string{"100000000"},
+ "status": []string{"RECEIVED"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_PaymailsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.PaymailFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "paymails query: with only metadata": {
+ query: &queries.Query[filter.PaymailFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "paymails query: with only page filter": {
+ query: &queries.Query[filter.PaymailFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "paymails query: with only model filter": {
+ query: &queries.Query[filter.PaymailFilter]{
+ Filter: filter.PaymailFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "paymails query: all fields set": {
+ query: &queries.Query[filter.PaymailFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.PaymailFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ ID: testutils.Ptr("b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"),
+ PublicName: testutils.Ptr("Alice"),
+ Alias: testutils.Ptr("alias"),
+ },
+ },
+ expectedValues: url.Values{
+ "publicName": []string{"Alice"},
+ "alias": []string{"alias"},
+ "id": []string{"b950f5de-3d3a-40b6-bdf8-c9d60e9e0a0a"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
+
+func TestQueryParser_Parse_ContactsQuery(t *testing.T) {
+ tests := map[string]struct {
+ query *queries.Query[filter.ContactFilter]
+ expectedValues url.Values
+ expectedErr error
+ }{
+ "contacts query: with only metadata": {
+ query: &queries.Query[filter.ContactFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ },
+ },
+ "contacts query: with only page filter": {
+ query: &queries.Query[filter.ContactFilter]{
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ },
+ expectedValues: url.Values{
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ },
+ },
+ "contacts query: with only model filter": {
+ query: &queries.Query[filter.ContactFilter]{
+ Filter: filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ },
+ },
+ expectedValues: url.Values{
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ "contacts query: all fields set": {
+ query: &queries.Query[filter.ContactFilter]{
+ Metadata: queryparams.Metadata{
+ "key1": "value1",
+ "key2": []string{"value2", "value3", "value4"},
+ "key3": queryparams.Metadata{
+ "key3_nested": "value5",
+ },
+ "key4": queryparams.Metadata{
+ "key4_nested": []int{6, 7},
+ },
+ },
+ PageFilter: filter.Page{
+ Number: 1,
+ Size: 2,
+ Sort: "asc",
+ SortBy: "key",
+ },
+ Filter: filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ CreatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ UpdatedRange: &filter.TimeRange{
+ From: testutils.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)),
+ To: testutils.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ ID: testutils.Ptr("e3a1e174-cdf8-4683-b112-e198144eb9d2"),
+ FullName: testutils.Ptr("John Doe"),
+ Paymail: testutils.Ptr("john.doe@test.com"),
+ Status: testutils.Ptr("confirmed"),
+ },
+ },
+ expectedValues: url.Values{
+ "paymail": []string{"john.doe@test.com"},
+ "status": []string{"confirmed"},
+ "id": []string{"e3a1e174-cdf8-4683-b112-e198144eb9d2"},
+ "fullName": []string{"John Doe"},
+ "page": []string{"1"},
+ "size": []string{"2"},
+ "sort": []string{"asc"},
+ "sortBy": []string{"key"},
+ "metadata[key1]": []string{"value1"},
+ "metadata[key2][]": []string{"value2", "value3", "value4"},
+ "metadata[key3][key3_nested]": []string{"value5"},
+ "metadata[key4][key4_nested][]": []string{"6", "7"},
+ "includeDeleted": []string{"true"},
+ "createdRange[from]": []string{"2021-01-01T00:00:00Z"},
+ "createdRange[to]": []string{"2021-01-02T00:00:00Z"},
+ "updatedRange[from]": []string{"2021-02-01T00:00:00Z"},
+ "updatedRange[to]": []string{"2021-02-02T00:00:00Z"},
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ parser, err := queryparams.NewQueryParser(tc.query)
+ require.NoError(t, err)
+
+ // when:
+ got, err := parser.Parse()
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedValues, got.Values)
+ })
+ }
+}
diff --git a/internal/api/v1/queryparams/url_values.go b/internal/api/v1/queryparams/url_values.go
new file mode 100644
index 00000000..69f3fdd5
--- /dev/null
+++ b/internal/api/v1/queryparams/url_values.go
@@ -0,0 +1,96 @@
+package queryparams
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+)
+
+type URLValues struct {
+ url.Values
+}
+
+func (u *URLValues) AddPair(key string, val any) {
+ if val == nil || len(key) == 0 {
+ return
+ }
+
+ write := func(v any) { u.Add(key, fmt.Sprintf("%v", v)) }
+ writeRange := func(v filter.TimeRange) {
+ if v.From != nil && !v.From.IsZero() {
+ u.Add(fmt.Sprintf("%s[from]", key), v.From.Format(time.RFC3339))
+ }
+
+ if v.To != nil && !v.To.IsZero() {
+ u.Add(fmt.Sprintf("%s[to]", key), v.To.Format(time.RFC3339))
+ }
+ }
+
+ switch v := val.(type) {
+ case int:
+ if v > 0 {
+ write(v)
+ }
+
+ case uint64:
+ if v > 0 {
+ write(v)
+ }
+
+ case bool:
+ write(v)
+
+ case string:
+ if len(v) > 0 {
+ write(v)
+ }
+
+ case *string:
+ if v != nil && len(*v) > 0 {
+ write(*v)
+ }
+
+ case *uint64:
+ if v != nil && *v > 0 {
+ write(*v)
+ }
+
+ case *uint32:
+ if v != nil && *v > 0 {
+ write(*v)
+ }
+
+ case *bool:
+ if v != nil {
+ write(*v)
+ }
+
+ case *filter.TimeRange:
+ if v != nil {
+ writeRange(*v)
+ }
+ }
+}
+
+func (u *URLValues) ParseToMap() map[string]string {
+ m := make(map[string]string)
+ for k, v := range u.Values {
+ m[k] = v[0]
+ }
+
+ return m
+}
+
+func (u *URLValues) Append(vv ...url.Values) {
+ for _, v := range vv {
+ for k, iv := range v {
+ u.Values[k] = append(u.Values[k], iv...)
+ }
+ }
+}
+
+func NewURLValues() *URLValues {
+ return &URLValues{make(url.Values)}
+}
diff --git a/internal/api/v1/queryparams/url_values_test.go b/internal/api/v1/queryparams/url_values_test.go
new file mode 100644
index 00000000..d8d7cbe2
--- /dev/null
+++ b/internal/api/v1/queryparams/url_values_test.go
@@ -0,0 +1,78 @@
+package queryparams_test
+
+import (
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/stretchr/testify/require"
+)
+
+func TestURLValues_AddPair(t *testing.T) {
+ // given:
+ to := testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z")
+ from := testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z")
+ expectedValues := url.Values{
+ "key1": []string{"str"},
+ "key2": []string{"1"},
+ "key3": []string{"str_ptr"},
+ "key4": []string{"64"},
+ "key5": []string{"32"},
+ "key6": []string{"false"},
+ "key7[from]": []string{from.Format(time.RFC3339)},
+ "key7[to]": []string{to.Format(time.RFC3339)},
+ }
+
+ // when:
+ params := queryparams.NewURLValues()
+ params.AddPair("key1", "str")
+ params.AddPair("key2", 1)
+ params.AddPair("key3", testutils.Ptr("str_ptr"))
+ params.AddPair("key4", testutils.Ptr(uint64(64)))
+ params.AddPair("key5", testutils.Ptr(uint32(32)))
+ params.AddPair("key6", testutils.Ptr(bool(false)))
+ params.AddPair("key7", &filter.TimeRange{
+ From: &from,
+ To: &to,
+ })
+
+ // then:
+ require.EqualValues(t, expectedValues, params.Values)
+}
+
+func TestURLValues_ParseToMap(t *testing.T) {
+ // given:
+ to := testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z")
+ from := testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z")
+ expectedValues := map[string]string{
+ "key1": "str",
+ "key2": "1",
+ "key3": "str_ptr",
+ "key4": "64",
+ "key5": "32",
+ "key6": "false",
+ "key7[from]": from.Format(time.RFC3339),
+ "key7[to]": to.Format(time.RFC3339),
+ }
+
+ params := queryparams.NewURLValues()
+ params.AddPair("key1", "str")
+ params.AddPair("key2", 1)
+ params.AddPair("key3", testutils.Ptr("str_ptr"))
+ params.AddPair("key4", testutils.Ptr(uint64(64)))
+ params.AddPair("key5", testutils.Ptr(uint32(32)))
+ params.AddPair("key6", testutils.Ptr(bool(false)))
+ params.AddPair("key7", &filter.TimeRange{
+ From: &from,
+ To: &to,
+ })
+
+ // when:
+ got := params.ParseToMap()
+
+ // then:
+ require.EqualValues(t, expectedValues, got)
+}
diff --git a/internal/api/v1/user/accesskeys/access_key_api.go b/internal/api/v1/user/accesskeys/access_key_api.go
new file mode 100644
index 00000000..4fcf90ce
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/access_key_api.go
@@ -0,0 +1,97 @@
+package accesskeys
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/users/current/keys"
+ api = "User Access Keys API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) {
+ var result response.AccessKey
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(cmd).
+ Post(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) {
+ var result response.AccessKey
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath(ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) AccessKeys(ctx context.Context, opts ...queries.QueryOption[filter.AccessKeyFilter]) (*queries.AccessKeyPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build access keys query params: %w", err)
+ }
+
+ var result response.PageModel[response.AccessKey]
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) RevokeAccessKey(ctx context.Context, ID string) error {
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ Delete(a.url.JoinPath(ID).String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/user/accesskeys/access_key_api_test.go b/internal/api/v1/user/accesskeys/access_key_api_test.go
new file mode 100644
index 00000000..5c125e52
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/access_key_api_test.go
@@ -0,0 +1,201 @@
+package accesskeys_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/accesskeys/accesskeystest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ accessKeysURL = "/api/v1/users/current/keys"
+ id = "1fb70cc2-e9d9-41a3-842e-f71cc58d9787"
+)
+
+func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.AccessKey
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/users/current/keys response: 200": {
+ expectedResponse: accesskeystest.ExpectedCreatedAccessKey(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("accesskeystest/post_access_key_200.json"),
+ },
+ "HTTP POST /api/v1/users/current/keys response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/users/current/keys response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/users/current/keys str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, accessKeysURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ got, err := wallet.GenerateAccessKey(context.Background(), &commands.GenerateAccessKey{
+ Metadata: map[string]any{"example_key": "example_value"},
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestAccessKeyAPI_AccessKey(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.AccessKey
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 200", id): {
+ expectedResponse: accesskeystest.ExpectedRertrivedAccessKey(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("accesskeystest/get_access_key_200.json"),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, accessKeysURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.AccessKey(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestAccessKeyAPI_AccessKeys(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.AccessKeyPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/users/current/keys response: 200": {
+ expectedResponse: accesskeystest.ExpectedAccessKeyPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("accesskeystest/get_access_keys_200.json"),
+ },
+ "HTTP GET /api/v1/users/current/keys response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/users/current/keys response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/users/current/keys str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, accessKeysURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.AccessKeyFilter]{
+ queries.QueryWithPageFilter[filter.AccessKeyFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.AccessKeyFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.AccessKeys(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) {
+
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 200", id): {
+ responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, testutils.NewBadRequestSPVError()),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, accessKeysURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.RevokeAccessKey(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/user/accesskeys/accesskeystest/access_keys_api_fixtures.go b/internal/api/v1/user/accesskeys/accesskeystest/access_keys_api_fixtures.go
new file mode 100644
index 00000000..127e10dd
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/accesskeystest/access_keys_api_fixtures.go
@@ -0,0 +1,88 @@
+package accesskeystest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedCreatedAccessKey(t *testing.T) *response.AccessKey {
+ return &response.AccessKey{
+ Model: response.Model{
+ Metadata: map[string]interface{}{
+ "key": "value",
+ },
+ CreatedAt: testutils.ParseTime(t, "2024-11-13T11:44:04.95481Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-13T12:44:04.954844+01:00"),
+ },
+ ID: "d8558b86-9382-4c42-8ebe-7cca5d8de60b",
+ XpubID: "345cef2e-36a7-4c28-b0a7-948bfdb03e5e",
+ Key: "dbb23e77-0467-4262-a0ef-3d30653866ae",
+ }
+}
+
+func ExpectedRertrivedAccessKey(t *testing.T) *response.AccessKey {
+ return &response.AccessKey{
+ Model: response.Model{
+ Metadata: map[string]interface{}{
+ "key": "value",
+ },
+ CreatedAt: testutils.ParseTime(t, "2024-11-13T11:44:04.95481Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-13T11:44:04.954844Z"),
+ },
+ ID: "1fb70cc2-e9d9-41a3-842e-f71cc58d9787",
+ XpubID: "e8d7d52f-01a1-4466-87fe-25a2225ef5e4",
+ }
+}
+
+func ExpectedAccessKeyPage(t *testing.T) *queries.AccessKeyPage {
+ ts1 := testutils.ParseTime(t, "2024-11-13T11:54:36.987563Z")
+ ts2 := testutils.ParseTime(t, "2024-11-08T13:43:18.599995Z")
+ return &queries.AccessKeyPage{
+ Content: []*response.AccessKey{
+ {
+ Model: response.Model{
+ Metadata: map[string]interface{}{
+ "key_1": "value_1",
+ },
+ CreatedAt: testutils.ParseTime(t, "2024-11-13T11:44:04.95481Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-13T11:54:36.988715Z"),
+ },
+ ID: "1f0504cd-d42d-4334-a441-a88a53aa47f8",
+ XpubID: "b271ae7e-ab17-4504-94c1-3a888f8b042a",
+ RevokedAt: &ts1,
+ },
+ {
+ Model: response.Model{
+ Metadata: map[string]interface{}{
+ "key_2": "value_2",
+ },
+ CreatedAt: testutils.ParseTime(t, "2024-11-13T11:07:43.595835Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-13T11:07:43.595876Z"),
+ },
+ ID: "41943e46-6999-409e-8dfd-d36ee75f1702",
+ XpubID: "3e32dd04-72bd-4cc5-92da-123c29708472",
+ },
+ {
+ Model: response.Model{
+ Metadata: map[string]interface{}{
+ "key_3": "value_3",
+ },
+ CreatedAt: testutils.ParseTime(t, "2024-11-08T13:43:18.554228Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-08T13:43:18.60036Z"),
+ },
+ ID: "41a87305-88f9-4d86-91f8-b2401078aaf9",
+ XpubID: "a035a7f0-2381-4d45-8a2d-197dd961f031",
+ RevokedAt: &ts2,
+ },
+ },
+ Page: response.PageDescription{
+ Size: 50,
+ Number: 1,
+ TotalElements: 7,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/user/accesskeys/accesskeystest/get_access_key_200.json b/internal/api/v1/user/accesskeys/accesskeystest/get_access_key_200.json
new file mode 100644
index 00000000..bf12135c
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/accesskeystest/get_access_key_200.json
@@ -0,0 +1,10 @@
+{
+ "createdAt": "2024-11-13T11:44:04.95481Z",
+ "updatedAt": "2024-11-13T11:44:04.954844Z",
+ "deletedAt": null,
+ "metadata": {
+ "key": "value"
+ },
+ "id": "1fb70cc2-e9d9-41a3-842e-f71cc58d9787",
+ "xpubId": "e8d7d52f-01a1-4466-87fe-25a2225ef5e4"
+ }
diff --git a/internal/api/v1/user/accesskeys/accesskeystest/get_access_keys_200.json b/internal/api/v1/user/accesskeys/accesskeystest/get_access_keys_200.json
new file mode 100644
index 00000000..403124a1
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/accesskeystest/get_access_keys_200.json
@@ -0,0 +1,42 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-13T11:44:04.95481Z",
+ "updatedAt": "2024-11-13T11:54:36.988715Z",
+ "deletedAt": null,
+ "metadata": {
+ "key_1": "value_1"
+ },
+ "id": "1f0504cd-d42d-4334-a441-a88a53aa47f8",
+ "xpubId": "b271ae7e-ab17-4504-94c1-3a888f8b042a",
+ "revokedAt": "2024-11-13T11:54:36.987563Z"
+ },
+ {
+ "createdAt": "2024-11-13T11:07:43.595835Z",
+ "updatedAt": "2024-11-13T11:07:43.595876Z",
+ "deletedAt": null,
+ "metadata": {
+ "key_2": "value_2"
+ },
+ "id": "41943e46-6999-409e-8dfd-d36ee75f1702",
+ "xpubId": "3e32dd04-72bd-4cc5-92da-123c29708472"
+ },
+ {
+ "createdAt": "2024-11-08T13:43:18.554228Z",
+ "updatedAt": "2024-11-08T13:43:18.60036Z",
+ "deletedAt": null,
+ "metadata": {
+ "key_3": "value_3"
+ },
+ "id": "41a87305-88f9-4d86-91f8-b2401078aaf9",
+ "xpubId": "a035a7f0-2381-4d45-8a2d-197dd961f031",
+ "revokedAt": "2024-11-08T13:43:18.599995Z"
+ }
+ ],
+ "page": {
+ "size": 50,
+ "number": 1,
+ "totalElements": 7,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/user/accesskeys/accesskeystest/post_access_key_200.json b/internal/api/v1/user/accesskeys/accesskeystest/post_access_key_200.json
new file mode 100644
index 00000000..b88bc813
--- /dev/null
+++ b/internal/api/v1/user/accesskeys/accesskeystest/post_access_key_200.json
@@ -0,0 +1,11 @@
+{
+ "createdAt": "2024-11-13T11:44:04.95481Z",
+ "updatedAt": "2024-11-13T12:44:04.954844+01:00",
+ "deletedAt": null,
+ "metadata": {
+ "key": "value"
+ },
+ "id": "d8558b86-9382-4c42-8ebe-7cca5d8de60b",
+ "xpubId": "345cef2e-36a7-4c28-b0a7-948bfdb03e5e",
+ "key": "dbb23e77-0467-4262-a0ef-3d30653866ae"
+}
diff --git a/internal/api/v1/user/contacts/contacts_api.go b/internal/api/v1/user/contacts/contacts_api.go
new file mode 100644
index 00000000..8bcec032
--- /dev/null
+++ b/internal/api/v1/user/contacts/contacts_api.go
@@ -0,0 +1,129 @@
+package contacts
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/contacts"
+ api = "User Contacts API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) Contacts(ctx context.Context, opts ...queries.QueryOption[filter.ContactFilter]) (*queries.ContactsPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build user contacts query params: %w", err)
+ }
+
+ var result queries.ContactsPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) {
+ var result response.Contact
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath(paymail).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) {
+ var result response.CreateContactResponse
+ _, err := a.httpClient.
+ R().
+ SetBody(cmd).
+ SetContext(ctx).
+ SetResult(&result).
+ Put(a.url.JoinPath(cmd.ContactPaymail).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &response.Contact{
+ Model: result.Contact.Model,
+ ID: result.Contact.ID,
+ FullName: result.Contact.FullName,
+ Paymail: result.Contact.Paymail,
+ PubKey: result.Contact.PubKey,
+ Status: result.Contact.Status,
+ }, nil
+}
+
+func (a *API) RemoveContact(ctx context.Context, paymail string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Delete(a.url.JoinPath(paymail).String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) ConfirmContact(ctx context.Context, paymail string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Post(a.url.JoinPath(paymail, "confirmation").String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) UnconfirmContact(ctx context.Context, paymail string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Delete(a.url.JoinPath(paymail, "confirmation").String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/user/contacts/contacts_api_test.go b/internal/api/v1/user/contacts/contacts_api_test.go
new file mode 100644
index 00000000..a04710bc
--- /dev/null
+++ b/internal/api/v1/user/contacts/contacts_api_test.go
@@ -0,0 +1,291 @@
+package contacts_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ contactsURL = "/api/v1/contacts"
+ paymail = "john.doe.test5@john.doe.test.4chain.space"
+ confirmationURI = "/confirmation"
+)
+
+func TestContactsAPI_Contacts(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.ContactsPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/contacts response: 200": {
+ expectedResponse: contactstest.ExpectedContactsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/get_contacts_200.json"),
+ },
+ "HTTP GET /api/v1/contacts response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, testutils.NewBadRequestSPVError()),
+ },
+ "HTTP GET /api/v1/contacts response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/contacts str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.ContactFilter]{
+ queries.QueryWithPageFilter[filter.ContactFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.ContactFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Contacts(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_ContactWithPaymail(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Contact
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 200", paymail): {
+ expectedResponse: contactstest.ExpectedContactWithWithPaymail(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/get_contact_paymail_200.json"),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/contacts/%s str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, paymail)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.ContactWithPaymail(context.Background(), paymail)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_UpsertContact(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Contact
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 200", paymail): {
+ expectedResponse: contactstest.ExpectedUpsertContact(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("contactstest/put_contact_upsert_200.json"),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PUT /api/v1/contacts/%s str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, paymail)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPut, url, tc.responder)
+
+ // when:
+ got, err := wallet.UpsertContact(context.Background(), commands.UpsertContact{
+ ContactPaymail: paymail,
+ FullName: "John Doe",
+ Metadata: map[string]any{"example_key": "example_val"},
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestContactsAPI_RemoveContact(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 200", paymail): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, paymail)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.RemoveContact(context.Background(), paymail)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestContactsAPI_ConfirmContact(t *testing.T) {
+ contact := &models.Contact{
+ Paymail: "alice@example.com",
+ PubKey: testutils.MockPKI(t, testutils.UserXPub),
+ }
+
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", contact.Paymail): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", contact.Paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 500", contact.Paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", contact.Paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, contact.Paymail, confirmationURI)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ aliceClient, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+ // given:
+ const period = 3600
+ const digits = 6
+
+ // when:
+ passcode, err := aliceClient.GenerateTotpForContact(contact, period, digits)
+
+ // then:
+ require.NoError(t, err)
+ require.NotEmpty(t, passcode)
+
+ err = aliceClient.ConfirmContact(context.Background(), contact, passcode, contact.Paymail, period, digits)
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestContactsAPI_UnconfirmContact(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 200", paymail): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, contactsURL, paymail, confirmationURI)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.UnconfirmContact(context.Background(), paymail)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go
new file mode 100644
index 00000000..0f028d72
--- /dev/null
+++ b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go
@@ -0,0 +1,75 @@
+package contactstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedContactsPage(t *testing.T) *queries.ContactsPage {
+ return &queries.ContactsPage{
+ Content: []*response.Contact{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-18T12:07:44.739839Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-18T15:08:44.739918Z"),
+ },
+ ID: "4f730efa-2a33-4275-bfdb-1f21fc110963",
+ FullName: "John Doe",
+ Paymail: "john.doe.test5@john.doe.4chain.space",
+ PubKey: "19751ea9-6c1f-4ba7-a7e2-551ef7930136",
+ Status: "unconfirmed",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-18T12:07:44.739839Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-18T15:08:44.739918Z"),
+ },
+ ID: "e55a4d4e-4a4b-4720-8556-1c00dd6a5cf3",
+ FullName: "Jane Doe",
+ Paymail: "jane.doe.test5@jane.doe.4chain.space",
+ PubKey: "f8898969-3f96-48d3-b122-bbb3e738dbf5",
+ Status: "unconfirmed",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 2,
+ Number: 2,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
+
+func ExpectedContactWithWithPaymail(t *testing.T) *response.Contact {
+ return &response.Contact{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-18T12:07:44.739839Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-18T15:08:44.739918Z"),
+ },
+ ID: "4f730efa-2a33-4275-bfdb-1f21fc110963",
+ FullName: "John Doe",
+ Paymail: "john.doe.test5@john.doe.4chain.space",
+ PubKey: "19751ea9-6c1f-4ba7-a7e2-551ef7930136",
+ Status: "unconfirmed",
+ }
+}
+
+func ExpectedUpsertContact(t *testing.T) *response.Contact {
+ return &response.Contact{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-18T12:07:44.739839Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-06T11:30:35.090124Z"),
+ Metadata: map[string]interface{}{
+ "example_key": "example_val",
+ },
+ },
+ ID: "68acf78f-5ece-4917-821d-8028ecf06c9a",
+ FullName: "John Doe",
+ Paymail: "john.doe.test@john.doe.test.4chain.space",
+ PubKey: "0df36839-67bb-49e7-a9c7-e839aa564871",
+ Status: "unconfirmed",
+ }
+}
diff --git a/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json b/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json
new file mode 100644
index 00000000..330de88c
--- /dev/null
+++ b/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json
@@ -0,0 +1,11 @@
+{
+ "createdAt": "2024-10-18T12:07:44.739839Z",
+ "updatedAt": "2024-10-18T15:08:44.739918Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "4f730efa-2a33-4275-bfdb-1f21fc110963",
+ "fullName": "John Doe",
+ "paymail": "john.doe.test5@john.doe.4chain.space",
+ "pubKey": "19751ea9-6c1f-4ba7-a7e2-551ef7930136",
+ "status": "unconfirmed"
+}
diff --git a/internal/api/v1/user/contacts/contactstest/get_contacts_200.json b/internal/api/v1/user/contacts/contactstest/get_contacts_200.json
new file mode 100644
index 00000000..661b3e6b
--- /dev/null
+++ b/internal/api/v1/user/contacts/contactstest/get_contacts_200.json
@@ -0,0 +1,32 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-10-18T12:07:44.739839Z",
+ "updatedAt": "2024-10-18T15:08:44.739918Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "4f730efa-2a33-4275-bfdb-1f21fc110963",
+ "fullName": "John Doe",
+ "paymail": "john.doe.test5@john.doe.4chain.space",
+ "pubKey": "19751ea9-6c1f-4ba7-a7e2-551ef7930136",
+ "status": "unconfirmed"
+ },
+ {
+ "createdAt": "2024-10-18T12:07:44.739839Z",
+ "updatedAt": "2024-10-18T15:08:44.739918Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "e55a4d4e-4a4b-4720-8556-1c00dd6a5cf3",
+ "fullName": "Jane Doe",
+ "paymail": "jane.doe.test5@jane.doe.4chain.space",
+ "pubKey": "f8898969-3f96-48d3-b122-bbb3e738dbf5",
+ "status": "unconfirmed"
+ }
+ ],
+ "page": {
+ "number": 2,
+ "size": 2,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json b/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json
new file mode 100644
index 00000000..38f5b15e
--- /dev/null
+++ b/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json
@@ -0,0 +1,16 @@
+{
+ "contact": {
+ "createdAt": "2024-10-18T12:07:44.739839Z",
+ "updatedAt": "2024-11-06T11:30:35.090124Z",
+ "deletedAt": null,
+ "metadata": {
+ "example_key": "example_val"
+ },
+ "id": "68acf78f-5ece-4917-821d-8028ecf06c9a",
+ "fullName": "John Doe",
+ "paymail": "john.doe.test@john.doe.test.4chain.space",
+ "pubKey": "0df36839-67bb-49e7-a9c7-e839aa564871",
+ "status": "unconfirmed"
+ },
+ "additionalInfo": {}
+}
diff --git a/internal/api/v1/user/invitations/invitations_api.go b/internal/api/v1/user/invitations/invitations_api.go
new file mode 100644
index 00000000..f3f0891a
--- /dev/null
+++ b/internal/api/v1/user/invitations/invitations_api.go
@@ -0,0 +1,50 @@
+package invitations
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/invitations"
+ api = "User Invitations API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) AcceptInvitation(ctx context.Context, paymail string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Post(a.url.JoinPath(paymail, "contacts").String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func (a *API) RejectInvitation(ctx context.Context, paymail string) error {
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ Delete(a.url.JoinPath(paymail).String())
+ if err != nil {
+ return fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/user/invitations/invitations_api_test.go b/internal/api/v1/user/invitations/invitations_api_test.go
new file mode 100644
index 00000000..57daaa96
--- /dev/null
+++ b/internal/api/v1/user/invitations/invitations_api_test.go
@@ -0,0 +1,97 @@
+package invitations_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ invitationsURL = "/api/v1/invitations"
+ paymail = "john.doe.test@john.doe.test.4chain.space"
+ contactsURI = "/contacts"
+)
+
+func TestInvitationsAPI_AcceptInvitation(t *testing.T) {
+
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 200", paymail): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, invitationsURL, paymail, contactsURI)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ err := wallet.AcceptInvitation(context.Background(), paymail)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestInvitationsAPI_RejectInvitation(t *testing.T) {
+
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 200", paymail): {
+ responder: testutils.NewStringResponderStatusOK(http.StatusText(http.StatusOK)),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 400", paymail): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 500", paymail): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP POST /api/v1/invitations/%s str response: 500", paymail): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, invitationsURL, paymail)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodDelete, url, tc.responder)
+
+ // when:
+ err := wallet.RejectInvitation(context.Background(), paymail)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
diff --git a/internal/api/v1/user/merkleroots/merkleroots_api.go b/internal/api/v1/user/merkleroots/merkleroots_api.go
new file mode 100644
index 00000000..57af1270
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merkleroots_api.go
@@ -0,0 +1,107 @@
+package merkleroots
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/merkleroots"
+ api = "User Merkle roots API"
+)
+
+// MerkleRootsRepository is an interface responsible for storing synchronized MerkleRoots and retrieving the last evaluation key from the database.
+type MerkleRootsRepository interface {
+ // GetLastMerkleRoot should return the Merkle root with the highest height from your memory, or undefined if empty.
+ GetLastMerkleRoot() string
+ // SaveMerkleRoots should store newly synced merkle roots into your storage;
+ // NOTE: items are sorted in ascending order by block height.
+ SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error
+}
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) MerkleRoots(ctx context.Context, merkleRootOpts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) {
+ var query queries.MerkleRootsQuery
+ for _, o := range merkleRootOpts {
+ o(&query)
+ }
+
+ params := queryparams.NewURLValues()
+ params.AddPair("batchSize", query.BatchSize)
+ params.AddPair("lastEvaluatedKey", query.LastEvaluatedKey)
+
+ var result queries.MerkleRootPage
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
+
+func (a *API) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error {
+ lastEvaluatedKey := repo.GetLastMerkleRoot()
+ previousLastEvaluatedKey := lastEvaluatedKey
+
+ for {
+ select {
+ case <-ctx.Done():
+ return goclienterr.ErrSyncMerkleRootsTimeout
+ default:
+ // Query the MerkleRoots API
+ result, err := a.MerkleRoots(ctx, queries.MerkleRootsQueryWithLastEvaluatedKey(lastEvaluatedKey))
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ return goclienterr.ErrSyncMerkleRootsTimeout
+ }
+ return fmt.Errorf("failed to fetch merkle roots from API: %w", err)
+ }
+
+ // Handle empty results
+ if len(result.Content) == 0 {
+ return nil
+ }
+
+ // Update the last evaluated key
+ lastEvaluatedKey = result.Page.LastEvaluatedKey
+ if lastEvaluatedKey != "" && previousLastEvaluatedKey == lastEvaluatedKey {
+ return goclienterr.ErrStaleLastEvaluatedKey
+ }
+
+ // Save fetched Merkle roots
+ err = repo.SaveMerkleRoots(result.Content)
+ if err != nil {
+ return fmt.Errorf("failed to save merkle roots: %w", err)
+ }
+
+ if lastEvaluatedKey == "" {
+ return nil
+ }
+
+ previousLastEvaluatedKey = lastEvaluatedKey
+ }
+ }
+}
diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go
new file mode 100644
index 00000000..9577a950
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go
@@ -0,0 +1,136 @@
+package merkleroots_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const merkleRootsURL = "/api/v1/merkleroots"
+
+func TestMerkleRootsAPI_MerkleRoots(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.MerkleRootPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/merkleroots response: 200": {
+ expectedResponse: merklerootstest.ExpectedMerkleRootsPage(),
+ responder: testutils.NewJSONFileResponderWithStatusOK("merklerootstest/get_merkleroots_200.json"),
+ },
+ "HTTP GET /api/v1/merkleroots response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/merkleroots response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/merkleroots str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, merkleRootsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.MerkleRootsQueryOption{
+ queries.MerkleRootsQueryWithBatchSize(1),
+ queries.MerkleRootsQueryWithLastEvaluatedKey("key"),
+ }
+ params := "batchSize=1&lastEvaluatedKey=key"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.MerkleRoots(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ setupMock func(mockRepo *testutils.MockMerkleRootsRepository)
+ expectedErr error
+ }{
+ "Successful Sync with Pagination": {
+ responder: testutils.NewPaginatedJSONResponder(t,
+ "merklerootstest/get_merkleroots_page1.json",
+ "merklerootstest/get_merkleroots_page2.json",
+ ),
+ setupMock: func(mockRepo *testutils.MockMerkleRootsRepository) {
+ testutils.SetupMerkleRootMockRepo(mockRepo, 2)
+ },
+ },
+ "Stale LastEvaluatedKey Error": {
+ responder: testutils.NewJSONFileResponderWithStatusOK("merklerootstest/get_merkleroots_stale.json"),
+ setupMock: func(mockRepo *testutils.MockMerkleRootsRepository) {
+ testutils.SetupStaleKeyMock(mockRepo)
+ },
+ expectedErr: errors.ErrStaleLastEvaluatedKey,
+ },
+ "API Returns Error Response": {
+ responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, testutils.NewInternalServerSPVError()),
+ setupMock: func(mockRepo *testutils.MockMerkleRootsRepository) {
+ testutils.SetupEmptyMerkleRootMock(mockRepo)
+ },
+ expectedErr: testutils.NewInternalServerSPVError(),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, merkleRootsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ mockRepo := new(testutils.MockMerkleRootsRepository)
+ httpmock.Activate()
+
+ defer httpmock.DeactivateAndReset()
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+ tc.setupMock(mockRepo)
+
+ // when:
+ err := wallet.SyncMerkleRoots(context.Background(), mockRepo)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully(t *testing.T) {
+ // given:
+ db := merklerootstest.CreateRepository([]models.MerkleRoot{})
+ url := testutils.FullAPIURL(t, merkleRootsURL)
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+
+ var expected []models.MerkleRoot
+ expected = append(expected, merklerootstest.FirstMerkleRootsPage().Content...)
+ expected = append(expected, merklerootstest.SecondMerkleRootsPage().Content...)
+ expected = append(expected, merklerootstest.ThirdMerkleRootsPage().Content...)
+
+ transport.RegisterResponder(http.MethodGet, url, merklerootstest.ResponderWithThreeMerkleRootPagesSuccess(t))
+
+ // when:
+ err := wallet.SyncMerkleRoots(context.Background(), db)
+
+ // then:
+ require.NoError(t, err)
+ require.Equal(t, expected, db.MerkleRoots)
+}
diff --git a/internal/api/v1/user/merkleroots/merkleroots_sync_test.go b/internal/api/v1/user/merkleroots/merkleroots_sync_test.go
new file mode 100644
index 00000000..b2671b4d
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merkleroots_sync_test.go
@@ -0,0 +1,105 @@
+package merkleroots_test
+
+import (
+ "context"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSyncMerkleRoots(t *testing.T) {
+ t.Run("Should properly sync database when empty", func(t *testing.T) {
+ // setup
+ server := merklerootstest.MockMerkleRootsAPIResponseNormal()
+ defer server.Close()
+
+ apiURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+
+ // given
+ repo := merklerootstest.CreateRepository([]models.MerkleRoot{})
+ client := merkleroots.NewAPI(apiURL, resty.New())
+
+ // when
+ err = client.SyncMerkleRoots(context.Background(), repo)
+
+ // then
+ require.NoError(t, err)
+ require.Len(t, repo.MerkleRoots, len(merklerootstest.MockedSPVWalletData))
+ require.Equal(t, merklerootstest.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1])
+ })
+
+ t.Run("Should properly sync database when partially filled", func(t *testing.T) {
+ // setup
+ server := merklerootstest.MockMerkleRootsAPIResponseNormal()
+ defer server.Close()
+
+ apiURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+
+ // given
+ client := merkleroots.NewAPI(apiURL, resty.New())
+ require.NoError(t, err)
+
+ repo := merklerootstest.CreateRepository([]models.MerkleRoot{})
+
+ // when
+ err = client.SyncMerkleRoots(context.Background(), repo)
+
+ // then
+ require.NoError(t, err)
+ require.Len(t, repo.MerkleRoots, len(merklerootstest.MockedSPVWalletData))
+ require.Equal(t, merklerootstest.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1])
+ })
+
+ t.Run("Should fail sync merkleroots due to the timeout", func(t *testing.T) {
+ // setup
+ server := merklerootstest.MockMerkleRootsAPIResponseDelayed()
+ defer server.Close()
+
+ apiURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+
+ // given
+ repo := merklerootstest.CreateRepository([]models.MerkleRoot{})
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
+ defer cancel()
+
+ client := merkleroots.NewAPI(apiURL, resty.New())
+ require.NoError(t, err)
+
+ // when
+ err = client.SyncMerkleRoots(ctx, repo)
+
+ // then
+ require.ErrorIs(t, err, goclienterr.ErrSyncMerkleRootsTimeout)
+ })
+
+ t.Run("Should fail sync merkleroots due to last evaluated key being the same in the response", func(t *testing.T) {
+ // setup
+ server := merklerootstest.MockMerkleRootsAPIResponseStale()
+ defer server.Close()
+
+ apiURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+
+ // given
+ repo := merklerootstest.CreateRepository([]models.MerkleRoot{})
+ client := merkleroots.NewAPI(apiURL, resty.New())
+ require.NoError(t, err)
+
+ // when
+ err = client.SyncMerkleRoots(context.Background(), repo)
+
+ // then
+ require.ErrorIs(t, err, errors.ErrStaleLastEvaluatedKey)
+ })
+}
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json
new file mode 100644
index 00000000..3ef31de8
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json
@@ -0,0 +1,23 @@
+{
+ "content": [
+ {
+ "blockHeight": 1,
+ "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689"
+ },
+ {
+ "blockHeight": 2,
+ "merkleRoot": "132a2a38-b23f-404b-940f-f811de886114"
+ },
+ {
+ "blockHeight": 3,
+ "merkleRoot": "d229c224-6c21-4c68-ba25-261119e9b8dc"
+ }
+ ],
+ "page": {
+ "lastEvaluatedKey": "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6",
+ "orderByField": "blockHeight",
+ "size": 20,
+ "sortDirection": "asc",
+ "totalElements": 10
+ }
+ }
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json
new file mode 100644
index 00000000..594c2be2
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json
@@ -0,0 +1,14 @@
+{
+ "content": [
+ { "blockHeight": 1, "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689" },
+ { "blockHeight": 2, "merkleRoot": "132a2a38-b23f-404b-940f-f811de886114" }
+ ],
+ "page": {
+ "lastEvaluatedKey": "key-1",
+ "orderByField": "blockHeight",
+ "size": 20,
+ "sortDirection": "asc",
+ "totalElements": 10
+ }
+ }
+
\ No newline at end of file
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json
new file mode 100644
index 00000000..a999a917
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json
@@ -0,0 +1,12 @@
+{
+ "content": [
+ { "blockHeight": 3, "merkleRoot": "d229c224-6c21-4c68-ba25-261119e9b8dc" }
+ ],
+ "page": {
+ "lastEvaluatedKey": "",
+ "orderByField": "blockHeight",
+ "size": 20,
+ "sortDirection": "asc",
+ "totalElements": 10
+ }
+}
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json
new file mode 100644
index 00000000..3456c12d
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json
@@ -0,0 +1,13 @@
+{
+ "content": [
+ { "blockHeight": 1, "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689" }
+ ],
+ "page": {
+ "lastEvaluatedKey": "stale-key",
+ "orderByField": "blockHeight",
+ "size": 20,
+ "sortDirection": "asc",
+ "totalElements": 10
+ }
+ }
+
\ No newline at end of file
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go
new file mode 100644
index 00000000..10278714
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go
@@ -0,0 +1,33 @@
+package merklerootstest
+
+import (
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+)
+
+func ExpectedMerkleRootsPage() *queries.MerkleRootPage {
+ return &queries.MerkleRootPage{
+ Content: []models.MerkleRoot{
+ {
+ MerkleRoot: "d02ab7b5-ac3e-4612-9377-9bffe05ac689",
+ BlockHeight: 1,
+ },
+ {
+ MerkleRoot: "132a2a38-b23f-404b-940f-f811de886114",
+ BlockHeight: 2,
+ },
+ {
+ MerkleRoot: "d229c224-6c21-4c68-ba25-261119e9b8dc",
+ BlockHeight: 3,
+ },
+ },
+ Page: models.ExclusiveStartKeyPageInfo{
+ OrderByField: testutils.Ptr("blockHeight"),
+ SortDirection: testutils.Ptr("asc"),
+ TotalElements: 10,
+ Size: 20,
+ LastEvaluatedKey: "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6",
+ },
+ }
+}
diff --git a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go
new file mode 100644
index 00000000..5d0e6ccc
--- /dev/null
+++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go
@@ -0,0 +1,354 @@
+package merklerootstest
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/jarcoal/httpmock"
+)
+
+// DB simulates a storage of Merkle roots for testing.
+type DB struct {
+ MerkleRoots []models.MerkleRoot
+}
+
+// SaveMerkleRoots appends synced Merkle roots to the simulated storage.
+func (db *DB) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error {
+ db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...)
+ return nil
+}
+
+// GetLastMerkleRoot retrieves the last Merkle root from storage.
+func (db *DB) GetLastMerkleRoot() string {
+ if len(db.MerkleRoots) == 0 {
+ return ""
+ }
+ return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot
+}
+
+// CreateRepository initializes a simulated repository with the provided Merkle roots.
+func CreateRepository(merkleRoots []models.MerkleRoot) *DB {
+ return &DB{
+ MerkleRoots: merkleRoots,
+ }
+}
+
+// sendJSONResponse sends a JSON response from the mock server.
+func sendJSONResponse(data any, w *http.ResponseWriter) {
+ (*w).Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(*w).Encode(data); err != nil {
+ (*w).WriteHeader(http.StatusInternalServerError)
+ }
+}
+
+// MockMerkleRootsAPIResponseNormal creates a mock server with normal API responses.
+func MockMerkleRootsAPIResponseNormal() *httptest.Server {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet {
+ lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey")
+ sendJSONResponse(MockedMerkleRootsAPIResponseFn(lastEvaluatedKey), &w)
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ return server
+}
+
+// MockMerkleRootsAPIResponseDelayed creates a mock server with delayed API responses.
+func MockMerkleRootsAPIResponseDelayed() *httptest.Server {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet {
+ lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey")
+ all := MockedMerkleRootsAPIResponseFn(lastEvaluatedKey)
+ if len(all.Content) > 3 {
+ all.Content = all.Content[:3]
+ }
+ all.Page.Size = len(all.Content)
+ if len(all.Content) > 0 {
+ all.Page.LastEvaluatedKey = all.Content[len(all.Content)-1].MerkleRoot
+ } else {
+ all.Page.LastEvaluatedKey = ""
+ }
+ time.Sleep(50 * time.Millisecond)
+ sendJSONResponse(all, &w)
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ return server
+}
+
+// MockMerkleRootsAPIResponseStale creates a mock server with a stale last evaluated key.
+func MockMerkleRootsAPIResponseStale() *httptest.Server {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet {
+ staleLastEvaluatedKeyResponse := models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
+ Content: []models.MerkleRoot{
+ {
+ MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
+ BlockHeight: 0,
+ },
+ {
+ MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
+ BlockHeight: 1,
+ },
+ {
+ MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
+ BlockHeight: 2,
+ },
+ },
+ Page: models.ExclusiveStartKeyPageInfo{
+ LastEvaluatedKey: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
+ Size: 3,
+ TotalElements: len(MockedSPVWalletData),
+ },
+ }
+ sendJSONResponse(staleLastEvaluatedKeyResponse, &w)
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ return server
+}
+
+// MockedSPVWalletData is mocked merkle roots data on spv-wallet side
+var MockedSPVWalletData = []models.MerkleRoot{
+ {
+ BlockHeight: 0,
+ MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
+ },
+ {
+ BlockHeight: 1,
+ MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
+ },
+ {
+ BlockHeight: 2,
+ MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
+ },
+ {
+ BlockHeight: 3,
+ MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644",
+ },
+ {
+ BlockHeight: 4,
+ MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a",
+ },
+ {
+ BlockHeight: 5,
+ MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1",
+ },
+ {
+ BlockHeight: 6,
+ MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37",
+ },
+ {
+ BlockHeight: 7,
+ MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f",
+ },
+ {
+ BlockHeight: 8,
+ MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3",
+ },
+ {
+ BlockHeight: 9,
+ MerkleRoot: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c9",
+ },
+ {
+ BlockHeight: 10,
+ MerkleRoot: "d3ad39fa52a89997ac7381c95eeffeaf40b66af7a57e9eba144be0a175a12b11",
+ },
+ {
+ BlockHeight: 11,
+ MerkleRoot: "f8325d8f7fa5d658ea143629288d0530d2710dc9193ddc067439de803c37066e",
+ },
+ {
+ BlockHeight: 12,
+ MerkleRoot: "3b96bb7e197ef276b85131afd4a09c059cc368133a26ca04ebffb0ab4f75c8b8",
+ },
+ {
+ BlockHeight: 13,
+ MerkleRoot: "9962d5c704ec27243364cbe9d384808feeac1c15c35ac790dffd1e929829b271",
+ },
+ {
+ BlockHeight: 14,
+ MerkleRoot: "e1afd89295b68bc5247fe0ca2885dd4b8818d7ce430faa615067d7bab8640156",
+ },
+}
+
+// LastMockedMerkleRoot returns last merkleroot value from MockedSPVWalletData
+func LastMockedMerkleRoot() models.MerkleRoot {
+ return MockedSPVWalletData[len(MockedSPVWalletData)-1]
+}
+
+func MockedMerkleRootsAPIResponseFn(lastMerkleRoot string) models.ExclusiveStartKeyPage[[]models.MerkleRoot] {
+ // If no lastMerkleRoot is provided, return the full dataset
+ if lastMerkleRoot == "" {
+ return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
+ Content: MockedSPVWalletData,
+ Page: models.ExclusiveStartKeyPageInfo{
+ LastEvaluatedKey: MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot, // Last Merkle root as key
+ TotalElements: len(MockedSPVWalletData),
+ Size: len(MockedSPVWalletData),
+ },
+ }
+ }
+
+ // Find the index of the lastMerkleRoot
+ lastMerkleRootIdx := slices.IndexFunc(MockedSPVWalletData, func(mr models.MerkleRoot) bool {
+ return mr.MerkleRoot == lastMerkleRoot
+ })
+
+ // If lastMerkleRoot is not found, return an empty response (or handle as error if desired)
+ if lastMerkleRootIdx == -1 {
+ return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
+ Content: []models.MerkleRoot{},
+ Page: models.ExclusiveStartKeyPageInfo{
+ LastEvaluatedKey: "",
+ TotalElements: len(MockedSPVWalletData),
+ Size: 0,
+ },
+ }
+ }
+
+ // If lastMerkleRoot is the highest in the server database, return no new content
+ if lastMerkleRootIdx >= len(MockedSPVWalletData)-1 {
+ return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
+ Content: []models.MerkleRoot{},
+ Page: models.ExclusiveStartKeyPageInfo{
+ LastEvaluatedKey: "",
+ TotalElements: len(MockedSPVWalletData),
+ Size: 0,
+ },
+ }
+ }
+
+ // Return all Merkle roots after the given lastMerkleRoot
+ content := MockedSPVWalletData[lastMerkleRootIdx+1:]
+
+ // Set the LastEvaluatedKey to the last Merkle root in the current page, or "" if it's the final one
+ lastEvaluatedKey := ""
+ if len(content) > 0 && content[len(content)-1].MerkleRoot != MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot {
+ lastEvaluatedKey = content[len(content)-1].MerkleRoot
+ }
+
+ return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{
+ Content: content,
+ Page: models.ExclusiveStartKeyPageInfo{
+ LastEvaluatedKey: lastEvaluatedKey,
+ TotalElements: len(MockedSPVWalletData),
+ Size: len(content),
+ },
+ }
+}
+
+func FirstMerkleRootsPage() *queries.MerkleRootPage {
+ return &queries.MerkleRootPage{
+ Content: []models.MerkleRoot{
+ {
+ BlockHeight: 0,
+ MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
+ },
+ {
+ BlockHeight: 1,
+ MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
+ },
+ {
+ BlockHeight: 2,
+ MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
+ },
+ },
+ Page: models.ExclusiveStartKeyPageInfo{
+ OrderByField: testutils.Ptr("blockHeight"),
+ SortDirection: testutils.Ptr("asc"),
+ TotalElements: 9,
+ Size: 3,
+ LastEvaluatedKey: "e4774f7a-eb99-4cac-956e-634d2aeccc93",
+ },
+ }
+}
+
+func SecondMerkleRootsPage() *queries.MerkleRootPage {
+ return &queries.MerkleRootPage{
+ Content: []models.MerkleRoot{
+ {
+ BlockHeight: 3,
+ MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644",
+ },
+ {
+ BlockHeight: 4,
+ MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a",
+ },
+ {
+ BlockHeight: 5,
+ MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1",
+ },
+ },
+ Page: models.ExclusiveStartKeyPageInfo{
+ OrderByField: testutils.Ptr("blockHeight"),
+ SortDirection: testutils.Ptr("asc"),
+ TotalElements: 9,
+ Size: 3,
+ LastEvaluatedKey: "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6",
+ },
+ }
+}
+
+func ThirdMerkleRootsPage() *queries.MerkleRootPage {
+ return &queries.MerkleRootPage{
+ Content: []models.MerkleRoot{
+ {
+ BlockHeight: 6,
+ MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37",
+ },
+ {
+ BlockHeight: 7,
+ MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f",
+ },
+ {
+ BlockHeight: 8,
+ MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3",
+ },
+ },
+ Page: models.ExclusiveStartKeyPageInfo{
+ OrderByField: testutils.Ptr("blockHeight"),
+ SortDirection: testutils.Ptr("asc"),
+ TotalElements: 9,
+ Size: 3,
+ LastEvaluatedKey: "09232c7e-ecf7-4e33-8feb-a32170c6e7b6",
+ },
+ }
+}
+
+func ResponderWithThreeMerkleRootPagesSuccess(t *testing.T) httpmock.Responder {
+ pages := map[int]*queries.MerkleRootPage{
+ 0: FirstMerkleRootsPage(),
+ 1: SecondMerkleRootsPage(),
+ 2: ThirdMerkleRootsPage(),
+ }
+
+ var num int
+ return func(r *http.Request) (*http.Response, error) {
+ defer func() { num++ }()
+
+ if num < len(pages) {
+ res, err := httpmock.NewJsonResponse(http.StatusPartialContent, pages[num])
+ if err != nil {
+ t.Fatalf("test helper - failed to generate new json response: %s", err)
+ }
+ return res, nil
+ }
+
+ res, err := httpmock.NewJsonResponse(http.StatusOK, queries.MerkleRootPage{})
+ if err != nil {
+ t.Fatalf("test helper - failed to generate new json response: %s", err)
+ }
+ return res, nil
+ }
+}
diff --git a/internal/api/v1/user/paymails/paymails_api.go b/internal/api/v1/user/paymails/paymails_api.go
new file mode 100644
index 00000000..859f35fd
--- /dev/null
+++ b/internal/api/v1/user/paymails/paymails_api.go
@@ -0,0 +1,55 @@
+package paymails
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/paymails"
+ api = "User Paymails API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) Paymails(ctx context.Context, opts ...queries.QueryOption[filter.PaymailFilter]) (*queries.PaymailsPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build paymail address query params: %w", err)
+ }
+
+ var result queries.PaymailsPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/user/paymails/paymails_api_test.go b/internal/api/v1/user/paymails/paymails_api_test.go
new file mode 100644
index 00000000..598e35d3
--- /dev/null
+++ b/internal/api/v1/user/paymails/paymails_api_test.go
@@ -0,0 +1,72 @@
+package paymails_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/paymails/paymailstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const paymailsURL = "/api/v1/paymails"
+
+func TestPaymailsAPI_Paymails(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.PaymailsPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/paymails response: 200": {
+ expectedResponse: paymailstest.ExpectedPaymailsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("paymailstest/get_paymails_200.json"),
+ },
+ "HTTP GET /api/v1/paymails response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/paymails response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/paymails str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, paymailsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.PaymailFilter]{
+ queries.QueryWithPageFilter[filter.PaymailFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.PaymailFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Paymails(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/user/paymails/paymailstest/get_paymails_200.json b/internal/api/v1/user/paymails/paymailstest/get_paymails_200.json
new file mode 100644
index 00000000..618bedd5
--- /dev/null
+++ b/internal/api/v1/user/paymails/paymailstest/get_paymails_200.json
@@ -0,0 +1,34 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-18T06:50:07.144902Z",
+ "updatedAt": "2024-11-18T06:50:07.144932Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "31b80181-4d8b-4766-9bc7-76a1d9c6b44d",
+ "xpubId": "69245a3a-f9ed-4046-9acb-9d66c0b3750c",
+ "alias": "john.doe.test",
+ "domain": "john.doe.4chain.space",
+ "publicName": "John Doe",
+ "avatar": ""
+ },
+ {
+ "createdAt": "2024-11-08T15:10:44.688653Z",
+ "updatedAt": "2024-11-18T07:19:51.561691Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "ec91273e-9fb7-4f10-9ecb-d1848d238814",
+ "xpubId": "68026cb6-a549-45e8-97b1-11426bb16769",
+ "alias": "jane.doe.test",
+ "domain": "jane.doe.4chain.space",
+ "publicName": "Jane Doe",
+ "avatar": "http://localhost:3003/static/paymail/avatar.jpg"
+ }
+ ],
+ "page": {
+ "size": 10,
+ "number": 1,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/user/paymails/paymailstest/paymail_api_fixtures.go b/internal/api/v1/user/paymails/paymailstest/paymail_api_fixtures.go
new file mode 100644
index 00000000..bb5f84b1
--- /dev/null
+++ b/internal/api/v1/user/paymails/paymailstest/paymail_api_fixtures.go
@@ -0,0 +1,45 @@
+package paymailstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedPaymailsPage(t *testing.T) *queries.PaymailsPage {
+ return &queries.PaymailsPage{
+ Content: []*response.PaymailAddress{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-18T06:50:07.144902Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T06:50:07.144932Z"),
+ },
+ ID: "31b80181-4d8b-4766-9bc7-76a1d9c6b44d",
+ XpubID: "69245a3a-f9ed-4046-9acb-9d66c0b3750c",
+ Alias: "john.doe.test",
+ Domain: "john.doe.4chain.space",
+ PublicName: "John Doe",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-08T15:10:44.688653Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-18T07:19:51.561691Z"),
+ },
+ ID: "ec91273e-9fb7-4f10-9ecb-d1848d238814",
+ XpubID: "68026cb6-a549-45e8-97b1-11426bb16769",
+ Alias: "jane.doe.test",
+ Domain: "jane.doe.4chain.space",
+ PublicName: "Jane Doe",
+ Avatar: "http://localhost:3003/static/paymail/avatar.jpg",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 10,
+ Number: 1,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/user/totp/totp.go b/internal/api/v1/user/totp/totp.go
new file mode 100644
index 00000000..cb064e54
--- /dev/null
+++ b/internal/api/v1/user/totp/totp.go
@@ -0,0 +1,150 @@
+package totp
+
+import (
+ "encoding/base32"
+ "encoding/hex"
+ "fmt"
+ "time"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ utils "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/pquerna/otp"
+ "github.com/pquerna/otp/totp"
+)
+
+const (
+ // DefaultPeriod - Default number of seconds a TOTP is valid for.
+ DefaultPeriod uint = 30
+ // DefaultDigits - Default TOTP length
+ DefaultDigits uint = 2
+)
+
+// API handles TOTP generation and validation.
+type API struct {
+ xPriv *bip32.ExtendedKey
+}
+
+func NewAPI(xPriv string) (*API, error) {
+ hdKey, err := bip32.GenerateHDKeyFromString(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate HD key from xPriv str: %w", err)
+ }
+
+ return &API{xPriv: hdKey}, nil
+}
+
+// GenerateTotpForContact generates a time-based one-time password (TOTP) for a contact.
+func (b *API) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) {
+ sharedSecret, err := b.makeSharedSecret(contact)
+ if err != nil {
+ return "", fmt.Errorf("generateTotpForContact: error when making shared: %w", err)
+ }
+
+ opts := getTotpOpts(period, digits)
+ passcode, err := totp.GenerateCodeCustom(directedSecret(sharedSecret, contact.Paymail), time.Now(), *opts)
+ if err != nil {
+ return "", fmt.Errorf("generateTotpForContact: error when generating TOTP: %w", err)
+ }
+ return passcode, nil
+}
+
+// ValidateTotpForContact validates a TOTP for a contact.
+func (b *API) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error {
+ sharedSecret, err := b.makeSharedSecret(contact)
+ if err != nil {
+ return fmt.Errorf("ValidateTotpForContact: error when making shared secret: %w", err)
+ }
+
+ opts := getTotpOpts(period, digits)
+ valid, err := totp.ValidateCustom(passcode, directedSecret(sharedSecret, requesterPaymail), time.Now(), *opts)
+ if err != nil {
+ return fmt.Errorf("ValidateTotpForContact: error when validating TOTP: %w", err)
+ }
+ if !valid {
+ return fmt.Errorf("ValidateTotpForContact: TOTP is invalid")
+ }
+ return nil
+}
+
+func (b *API) makeSharedSecret(contact *models.Contact) ([]byte, error) {
+ privKey, pubKey, err := b.getSharedSecretFactors(contact)
+ if err != nil {
+ return nil, fmt.Errorf("makeSharedSecret: error when getting shared secret factors: %w", err)
+ }
+
+ x, _ := ec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes())
+ return x.Bytes(), nil
+}
+
+func (b *API) getSharedSecretFactors(contact *models.Contact) (*ec.PrivateKey, *ec.PublicKey, error) {
+ if b.xPriv == nil {
+ return nil, nil, errors.ErrMissingXpriv
+ }
+
+ // Derive private key from xPriv for PKI operations.
+ xpriv, err := deriveXprivForPki(b.xPriv)
+ if err != nil {
+ return nil, nil, fmt.Errorf("getSharedSecretFactors: error when deriving xpriv for PKI: %w", err)
+ }
+
+ privKey, err := xpriv.ECPrivKey()
+ if err != nil {
+ return nil, nil, fmt.Errorf("getSharedSecretFactors: error when deriving private key: %w", err)
+ }
+
+ // Convert contact's public key.
+ pubKey, err := convertPubKey(contact.PubKey)
+ if err != nil {
+ return nil, nil, errors.ErrContactPubKeyInvalid
+ }
+
+ return privKey, pubKey, nil
+}
+
+func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) {
+ pkiXpriv, err := bip32.GetHDKeyByPath(xpriv, utils.ChainExternal, 0)
+ if err != nil {
+ return nil, fmt.Errorf("deriveXprivForPki: error when deriving xpriv for PKI: %w", err)
+ }
+ pki, err := pkiXpriv.Child(0)
+ if err != nil {
+ return nil, fmt.Errorf("deriveXprivForPki: error when deriving xpriv for PKI: %w", err)
+ }
+ return pki, nil
+}
+
+func convertPubKey(pubKey string) (*ec.PublicKey, error) {
+ decoded, err := hex.DecodeString(pubKey)
+ if err != nil {
+ return nil, fmt.Errorf("convertPubKey: error when decoding public key: %w", err)
+ }
+
+ parsedPubKey, err := ec.ParsePubKey(decoded)
+ if err != nil {
+ return nil, fmt.Errorf("convertPubKey: error when parsing public key: %w", err)
+ }
+ return parsedPubKey, nil
+}
+
+func getTotpOpts(period, digits uint) *totp.ValidateOpts {
+ if period == 0 {
+ period = DefaultPeriod
+ }
+
+ if digits == 0 {
+ digits = DefaultDigits
+ }
+
+ return &totp.ValidateOpts{
+ Period: period,
+ Digits: otp.Digits(digits), //nolint: gosec
+ }
+}
+
+// directedSecret appends a paymail to the shared secret and encodes it as base32.
+func directedSecret(sharedSecret []byte, paymail string) string {
+ return base32.StdEncoding.EncodeToString(append(sharedSecret, []byte(paymail)...))
+}
diff --git a/internal/api/v1/user/totp/totp_test.go b/internal/api/v1/user/totp/totp_test.go
new file mode 100644
index 00000000..3850a0b3
--- /dev/null
+++ b/internal/api/v1/user/totp/totp_test.go
@@ -0,0 +1,105 @@
+package totp_test
+
+import (
+ "testing"
+ "time"
+
+ client "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClient_GenerateTotpForContact(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ // given
+ contact := models.Contact{PubKey: testutils.PubKey}
+ wc, err := totp.NewAPI(testutils.UserXPriv)
+ require.NoError(t, err)
+
+ // when
+ pass, err := wc.GenerateTotpForContact(&contact, 30, 2)
+
+ // then
+ require.NoError(t, err)
+ require.Len(t, pass, 2)
+ })
+
+ t.Run("contact has invalid PubKey - returns error", func(t *testing.T) {
+ // given
+ contact := models.Contact{PubKey: "invalid-pk-format"}
+ wc, err := totp.NewAPI(testutils.UserXPriv)
+ require.NoError(t, err)
+
+ // when
+ _, err = wc.GenerateTotpForContact(&contact, 30, 2)
+
+ // then
+ require.ErrorIs(t, err, errors.ErrContactPubKeyInvalid)
+ })
+}
+
+func TestClient_ValidateTotpForContact(t *testing.T) {
+ cfg := config.Config{
+ Addr: testutils.TestAPIAddr,
+ Timeout: 5 * time.Second,
+ }
+ t.Run("success", func(t *testing.T) {
+ // given
+ clientAlice, err := client.NewUserAPIWithXPriv(cfg, testutils.AliceXPriv)
+ require.NoError(t, err)
+
+ clientBob, err := client.NewUserAPIWithXPriv(cfg, testutils.BobXPriv)
+ require.NoError(t, err)
+
+ // and
+ aliceContact := &models.Contact{
+ PubKey: testutils.MockPKI(t, testutils.AliceXPub),
+ Paymail: "alice@example.com",
+ }
+
+ bobContact := &models.Contact{
+ PubKey: testutils.MockPKI(t, testutils.BobXPub),
+ Paymail: "bob@example.com",
+ }
+
+ // when
+ passcode, err := clientAlice.GenerateTotpForContact(bobContact, 3600, 6)
+
+ // then
+ require.NoError(t, err)
+
+ // when
+ err = clientBob.ValidateTotpForContact(aliceContact, passcode, bobContact.Paymail, 3600, 6)
+
+ // then
+ require.NoError(t, err)
+ })
+
+ t.Run("contact has invalid PubKey - returns error", func(t *testing.T) {
+ // given
+ sut, err := client.NewUserAPIWithXPriv(cfg, testutils.UserXPriv)
+ require.NoError(t, err)
+
+ // and
+ invalidContact := &models.Contact{
+ PubKey: "invalid_pub_key_format",
+ Paymail: "invalid@example.com",
+ }
+
+ // when
+ err = sut.ValidateTotpForContact(invalidContact, "123456", "someone@example.com", 3600, 6)
+
+ // when
+ require.Contains(t, err.Error(), "contact's PubKey is invalid")
+ })
+
+ t.Run("xpriv empty", func(t *testing.T) {
+ _, err := client.NewUserAPIWithXPriv(cfg, "")
+ require.Error(t, err)
+ require.ErrorIs(t, err, errors.ErrEmptyXprivKey)
+ })
+}
diff --git a/internal/api/v1/user/transactions/transaction_signer.go b/internal/api/v1/user/transactions/transaction_signer.go
new file mode 100644
index 00000000..b432de83
--- /dev/null
+++ b/internal/api/v1/user/transactions/transaction_signer.go
@@ -0,0 +1,100 @@
+package transactions
+
+import (
+ "errors"
+ "fmt"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
+ "github.com/bitcoin-sv/go-sdk/script"
+ trx "github.com/bitcoin-sv/go-sdk/transaction"
+ sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash"
+ "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh"
+ walleterrors "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+type noopTransactionSigner struct {
+}
+
+func (*noopTransactionSigner) TransactionSignedHex(dt *response.DraftTransaction) (string, error) {
+ return "", nil
+}
+
+type xPrivTransactionSigner struct {
+ xPriv *bip32.ExtendedKey
+}
+
+func NewXPrivTransactionSigner(xPriv string) (*xPrivTransactionSigner, error) {
+ hdKey, err := bip32.GenerateHDKeyFromString(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate HD key from xPriv str: %w", err)
+ }
+
+ return &xPrivTransactionSigner{xPriv: hdKey}, nil
+}
+
+func (ts *xPrivTransactionSigner) TransactionSignedHex(dt *response.DraftTransaction) (string, error) {
+ // Create transaction from hex
+ tx, err := trx.NewTransactionFromHex(dt.Hex)
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrFailedToParseHex, err)
+ }
+ // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign
+ tx.Inputs = make([]*trx.TransactionInput, 0)
+
+ // Enrich inputs
+ for _, draftInput := range dt.Configuration.Inputs {
+ lockingScript, err := script.NewFromHex(draftInput.Destination.LockingScript)
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrCreateLockingScript, err)
+ }
+
+ // prepare unlocking script
+ key, err := getDerivedKeyForDestination(ts.xPriv, &draftInput.Destination)
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrGetDerivedKeyForDestination, err)
+ }
+ sigHashFlags := sighash.AllForkID
+ unlockScript, err := p2pkh.Unlock(key, &sigHashFlags)
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrCreateUnlockingScript, err)
+ }
+
+ err = tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript)
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrAddInputsToTransaction, err)
+ }
+ }
+
+ err = tx.Sign()
+ if err != nil {
+ return "", errors.Join(walleterrors.ErrSignTransaction, err)
+ }
+
+ return tx.String(), nil
+}
+
+func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *response.Destination) (*ec.PrivateKey, error) {
+ // Derive the child key (m/chain/num)
+ derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num)
+ if err != nil {
+ return nil, fmt.Errorf("failed to derive key for unlocking input, %w", err)
+ }
+
+ // Handle paymail destination derivation if applicable
+ if dst.PaymailExternalDerivationNum != nil {
+ derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum)
+ if err != nil {
+ return nil, fmt.Errorf("failed to derive key for unlocking paymail input, %w", err)
+ }
+ }
+
+ // Get the private key from the derived key
+ priv, err := bip32.GetPrivateKeyFromHDKey(derivedKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get private key for unlocking paymail input, %w", err)
+ }
+
+ return priv, nil
+}
diff --git a/internal/api/v1/user/transactions/transactions_api.go b/internal/api/v1/user/transactions/transactions_api.go
new file mode 100644
index 00000000..aae59b5d
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactions_api.go
@@ -0,0 +1,181 @@
+package transactions
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/transactions"
+ api = "User Transactions API"
+)
+
+type TransactionSigner interface {
+ TransactionSignedHex(dt *response.DraftTransaction) (string, error)
+}
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+ transactionSigner TransactionSigner
+}
+
+func (a *API) FinalizeTransaction(draft *response.DraftTransaction) (string, error) {
+ hex, err := a.transactionSigner.TransactionSignedHex(draft)
+ if err != nil {
+ return "", fmt.Errorf("failed to finalize transaction: %w", err)
+ }
+
+ return hex, nil
+}
+
+func (a *API) DraftToRecipients(ctx context.Context, r *commands.SendToRecipients) (*response.DraftTransaction, error) {
+ outputs := make([]*response.TransactionOutput, 0)
+
+ for _, recipient := range r.Recipients {
+ outputs = append(outputs, &response.TransactionOutput{
+ To: recipient.To,
+ Satoshis: recipient.Satoshis,
+ OpReturn: recipient.OpReturn,
+ })
+ }
+
+ return a.DraftTransaction(ctx, &commands.DraftTransaction{
+ Config: response.TransactionConfig{
+ Outputs: outputs,
+ },
+ Metadata: r.Metadata,
+ })
+}
+
+func (a *API) SendToRecipients(ctx context.Context, r *commands.SendToRecipients) (*response.Transaction, error) {
+ draft, err := a.DraftToRecipients(ctx, r)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send draft to recipients: %w", err)
+ }
+
+ var hex string
+ if hex, err = a.FinalizeTransaction(draft); err != nil {
+ return nil, fmt.Errorf("failed to finalize transaction: %w", err)
+ }
+
+ return a.RecordTransaction(ctx, &commands.RecordTransaction{
+ Metadata: r.Metadata,
+ Hex: hex,
+ ReferenceID: draft.ID,
+ })
+}
+
+func (a *API) DraftTransaction(ctx context.Context, r *commands.DraftTransaction) (*response.DraftTransaction, error) {
+ var result response.DraftTransaction
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(r).
+ Post(a.url.JoinPath("drafts").String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) RecordTransaction(ctx context.Context, r *commands.RecordTransaction) (*response.Transaction, error) {
+ var result response.Transaction
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(r).
+ Post(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) UpdateTransactionMetadata(ctx context.Context, r *commands.UpdateTransactionMetadata) (*response.Transaction, error) {
+ var result response.Transaction
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(r).
+ Patch(a.url.JoinPath(r.ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Transaction(ctx context.Context, ID string) (*response.Transaction, error) {
+ var result response.Transaction
+
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.JoinPath(ID).String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) Transactions(ctx context.Context, opts ...queries.QueryOption[filter.TransactionFilter]) (*queries.TransactionPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transactions query params: %w", err)
+ }
+
+ var result response.PageModel[response.Transaction]
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPIWithXPriv(URL *url.URL, httpClient *resty.Client, xPriv string) (*API, error) {
+ transactionSigner, err := NewXPrivTransactionSigner(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transactionSigner: %w", err)
+ }
+
+ return &API{
+ url: URL.JoinPath(route),
+ httpClient: httpClient,
+ transactionSigner: transactionSigner},
+ nil
+}
+
+func NewAPI(URL *url.URL, httpClient *resty.Client) (*API, error) {
+ return &API{
+ url: URL.JoinPath(route),
+ httpClient: httpClient,
+ transactionSigner: &noopTransactionSigner{},
+ }, nil
+}
diff --git a/internal/api/v1/user/transactions/transactions_api_test.go b/internal/api/v1/user/transactions/transactions_api_test.go
new file mode 100644
index 00000000..7a5d3b3a
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactions_api_test.go
@@ -0,0 +1,381 @@
+package transactions_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ transactionsURL = "/api/v1/transactions"
+ transactionDraftURL = "/api/v1/transactions/drafts"
+)
+
+func TestTransactionsAPI_SendToRecipients(t *testing.T) {
+ drafTransactionURL := testutils.FullAPIURL(t, transactionDraftURL)
+ recordTransactionURL := testutils.FullAPIURL(t, transactionsURL)
+ opReturn := &response.OpReturn{StringParts: []string{"hello", "world"}}
+
+ t.Run("SendToRecipients success", func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, drafTransactionURL, testutils.NewJSONFileResponderWithStatusOK("transactionstest/transaction_draft_with_hex_200.json"))
+ transport.RegisterResponder(http.MethodPost, recordTransactionURL, testutils.NewJSONFileResponderWithStatusOK("transactionstest/transaction_send_to_recipients_200.json"))
+ ctx := context.Background()
+
+ // when:
+ result, err := wallet.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{
+ {
+ OpReturn: opReturn,
+ },
+ },
+ })
+
+ // then:
+ require.ErrorIs(t, err, nil)
+ require.Equal(t, transactionstest.ExpectedSendToRecipientsTransaction(t), result)
+ })
+
+ t.Run("SendToRecipients - DraftToRecipients error", func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, drafTransactionURL, testutils.NewBadRequestSPVErrorResponder())
+ ctx := context.Background()
+
+ // when:
+ result, err := wallet.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{
+ {
+ OpReturn: opReturn,
+ },
+ },
+ })
+
+ // then:
+ require.ErrorIs(t, err, testutils.NewBadRequestSPVError())
+ require.Nil(t, result)
+ })
+
+ t.Run("SendToRecipients - FinalizeTransaction error", func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, drafTransactionURL, testutils.NewJSONBodyResponderWithStatusOK(transactionstest.ExpectedDraftTransactionWithWrongHex(t)))
+ ctx := context.Background()
+
+ // when:
+ result, err := wallet.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{
+ {
+ OpReturn: opReturn,
+ },
+ },
+ })
+
+ // then:
+ require.Error(t, err)
+ require.Nil(t, result)
+ })
+
+ t.Run("SendToRecipients - RecordTransaction error", func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, drafTransactionURL, testutils.NewJSONFileResponderWithStatusOK("transactionstest/transaction_draft_with_hex_200.json"))
+ transport.RegisterResponder(http.MethodPost, recordTransactionURL, testutils.NewBadRequestSPVErrorResponder())
+ ctx := context.Background()
+
+ // when:
+ result, err := wallet.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{
+ {
+ OpReturn: opReturn,
+ },
+ },
+ })
+
+ // then:
+ require.ErrorIs(t, err, testutils.NewBadRequestSPVError())
+ require.Nil(t, result)
+ })
+}
+
+func TestTransactionsAPI_FinalizeTransaction(t *testing.T) {
+ tests := map[string]struct {
+ draft *response.DraftTransaction
+ expectedHex string
+ expectedErr error
+ }{
+ "Finalize Transaction with proper draft": {
+ draft: transactionstest.ExpectedDraftTransactionWithHex(t),
+ expectedHex: "01000000014c037d55e72d2ee6a95ff67bd758c4cee9c7545bb4d72ba77584152fcfa07012000000006b483045022100a01c25ad9a306f747d90a6d0e815795416ee1f004f865b0653ae3eb2939f42d90220110d994aa99f10533d2566317f55cab838b40f333bf4cdf30c82246461c31fef412102af82c4f5cac25cb5062364937c5e2286094b709610e60b7997b6715784dbf91effffffff0200000000000000000e006a0568656c6c6f05776f726c6408000000000000001976a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac00000000",
+ },
+ "Finalize Transaction fail to parse hex": {
+ draft: transactionstest.ExpectedDraftTransactionWithWrongHex(t),
+ expectedErr: errors.ErrFailedToParseHex,
+ },
+ "Finalize Transaction fail to prepare locking script": {
+ draft: transactionstest.ExpectedDraftTransactionWithWrongLockingScript(t),
+ expectedErr: errors.ErrCreateLockingScript,
+ },
+ "Finalize Transaction fail to add inputs to transaction": {
+ draft: transactionstest.ExpectedDraftTransactionWithWrongInputs(t),
+ expectedErr: errors.ErrAddInputsToTransaction,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ //given:
+ wallet, _ := testutils.GivenSPVUserAPI(t)
+
+ //when:
+ hex, err := wallet.FinalizeTransaction(tc.draft)
+
+ //then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedHex, hex)
+ })
+ }
+}
+
+func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) {
+ id := "1024"
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Transaction
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 200", id): {
+ expectedResponse: transactionstest.ExpectedTransactionWithUpdatedMetadata(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/patch_transaction_update_metadata_200.json"),
+ },
+ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPatch, url, tc.responder)
+
+ // when:
+ got, err := wallet.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{
+ ID: id,
+ Metadata: queryparams.Metadata{
+ "example_key1": "example_key10_val",
+ "example_key2": "example_key20_val",
+ },
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestTransactionsAPI_RecordTransaction(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Transaction
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/transactions response: 201": {
+ expectedResponse: transactionstest.ExpectedRecordTransaction(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/post_transaction_record_201.json"),
+ },
+ "HTTP POST /api/v1/transactions response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/transactions response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/transactions str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ got, err := wallet.RecordTransaction(context.Background(), &commands.RecordTransaction{})
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestTransactionsAPI_DraftTransaction(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.DraftTransaction
+ expectedErr error
+ }{
+ "HTTP POST /api/v1/transactions/drafts response: 200": {
+ expectedResponse: transactionstest.ExpectedDraftTransaction(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/post_transaction_draft_200.json"),
+ },
+ "HTTP POST /api/v1/transactions/drafts response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/transactions/drafts response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP POST /api/v1/transactions/drafts str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionDraftURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPost, url, tc.responder)
+
+ // when:
+ got, err := wallet.DraftTransaction(context.Background(), &commands.DraftTransaction{
+ Config: response.TransactionConfig{},
+ Metadata: map[string]any{},
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestTransactionsAPI_Transaction(t *testing.T) {
+ id := "1024"
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Transaction
+ expectedErr error
+ }{
+ fmt.Sprintf("HTTP GET /api/v1/transactions/%s response: 200", id): {
+ expectedResponse: transactionstest.ExpectedTransaction(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/get_transaction_200.json"),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/transactions/%s response: 400", id): {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/transactions/%s response: 500", id): {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ fmt.Sprintf("HTTP GET /api/v1/transactions/%s str response: 500", id): {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL, id)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.Transaction(context.Background(), id)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestTransactionsAPI_Transactions(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.PageModel[response.Transaction]
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/transactions response: 200": {
+ expectedResponse: transactionstest.ExpectedTransactionsPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("transactionstest/get_transactions_200.json"),
+ },
+ "HTTP GET /api/v1/transactions response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/transactions response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/transactions str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, transactionsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.TransactionFilter]{
+ queries.QueryWithPageFilter[filter.TransactionFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.TransactionFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.Transactions(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/get_transaction_200.json b/internal/api/v1/user/transactions/transactionstest/get_transaction_200.json
new file mode 100644
index 00000000..0e5f10a7
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/get_transaction_200.json
@@ -0,0 +1,33 @@
+{
+ "createdAt": "2024-10-07T14:03:26.736816Z",
+ "updatedAt": "2024-10-07T14:03:26.736816Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "ip_address": "127.0.0.01",
+ "p2p_tx_metadata": {
+ "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd",
+ "sender": "john.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "user_agent": "node-fetch"
+ },
+ "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ "hex": "283b1c6deb6d6263b3cec7a4701d46d3",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "4c9a0a0d-ea4f-4f03-b740-84438b3d210d"
+ ],
+ "blockHash": "47758f612c6bf5b454bcd642fe8031f6",
+ "blockHeight": 512,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 311,
+ "outputValue": 100,
+ "status": "MINED",
+ "direction": "incoming"
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/get_transactions_200.json b/internal/api/v1/user/transactions/transactionstest/get_transactions_200.json
new file mode 100644
index 00000000..ebf92671
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/get_transactions_200.json
@@ -0,0 +1,76 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-10-07T14:03:26.736816Z",
+ "updatedAt": "2024-10-07T14:03:26.736816Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "ip_address": "127.0.0.01",
+ "p2p_tx_metadata": {
+ "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065",
+ "sender": "john.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "user_agent": "node-fetch"
+ },
+ "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ "hex": "283b1c6deb6d6263b3cec7a4701d46d3",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "4c9a0a0d-ea4f-4f03-b740-84438b3d210d"
+ ],
+ "blockHash": "47758f612c6bf5b454bcd642fe8031f6",
+ "blockHeight": 512,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 311,
+ "outputValue": 100,
+ "status": "MINED",
+ "direction": "incoming"
+ },
+ {
+ "createdAt": "2024-10-07T14:03:26.736816Z",
+ "updatedAt": "2024-10-07T14:03:26.736816Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "jane.doe.test.4chain.space",
+ "example_key101": "example_key101_val",
+ "ip_address": "127.0.0.01",
+ "p2p_tx_metadata": {
+ "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa",
+ "sender": "jane.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50",
+ "user_agent": "node-fetch"
+ },
+ "id": "1c110e11-c23a-51e5-a7e7-99c12b01233e",
+ "hex": "283b1c7deb7d7773b3cec7a8801d47d2",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "2c8a1a1d-ea5f-5f04-b890-92418b2d411d"
+ ],
+ "blockHash": "56659f622c6bf5b554bcd742fe8132f9",
+ "blockHeight": 1024,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 500,
+ "outputValue": 200,
+ "status": "MINED",
+ "direction": "incoming"
+ }
+ ],
+ "page": {
+ "number": 2,
+ "size": 2,
+ "totalElements": 2,
+ "totalPages": 1
+ }
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/patch_transaction_update_metadata_200.json b/internal/api/v1/user/transactions/transactionstest/patch_transaction_update_metadata_200.json
new file mode 100644
index 00000000..c5265c05
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/patch_transaction_update_metadata_200.json
@@ -0,0 +1,34 @@
+{
+ "createdAt": "2024-10-07T14:03:26.736816Z",
+ "updatedAt": "2024-10-07T14:03:26.736816Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "example_key2": "example_key20_val",
+ "ip_address": "127.0.0.01",
+ "p2p_tx_metadata": {
+ "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd",
+ "sender": "john.doe@handcash.io"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "user_agent": "node-fetch"
+ },
+ "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ "hex": "283b1c6deb6d6263b3cec7a4701d46d3",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "4c9a0a0d-ea4f-4f03-b740-84438b3d210d"
+ ],
+ "blockHash": "47758f612c6bf5b454bcd642fe8031f6",
+ "blockHeight": 512,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 311,
+ "outputValue": 100,
+ "status": "MINED",
+ "direction": "incoming"
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/post_transaction_draft_200.json b/internal/api/v1/user/transactions/transactionstest/post_transaction_draft_200.json
new file mode 100644
index 00000000..4ceae768
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/post_transaction_draft_200.json
@@ -0,0 +1,127 @@
+{
+ "createdAt": "2024-11-05T07:30:14.219077Z",
+ "updatedAt": "2024-11-05T07:30:14.219077Z",
+ "deletedAt": null,
+ "metadata": {
+ "receiver": "john.doe.test4@john.doe.test.4chain.space",
+ "sender": "john.doe.test4@john.doe.test.4chain.space"
+ },
+ "id": "36be741b-31c7-4aed-8840-5e5b2eafeb41",
+ "hex": "c959fdb6-f438-4ef9-aef9-92a1852885ef",
+ "xpubId": "3f0a90d3-4f8b-45f6-81e4-9858fa47ecc0",
+ "expiresAt": "2024-11-05T07:30:27.372912Z",
+ "configuration": {
+ "changeDestinations": [
+ {
+ "createdAt": "2024-11-05T07:30:14.219077Z",
+ "updatedAt": "2024-11-05T07:30:14.219077Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "c86dd8f4-316f-4d71-be00-7bd1a38079e4",
+ "xpubId": "d6884260-1624-415b-8625-652a59345ead",
+ "lockingScript": "189593db-0048-4fb7-80da-b69bce8fbf78",
+ "type": "pubkeyhash",
+ "chain": 1,
+ "num": 5,
+ "paymailExternalDerivationNum": null,
+ "address": "3f96ea59-ac83-476e-a0ea-f0d668086081",
+ "draftId": "fc60742e-92b5-4a98-90a7-422d89879494"
+ }
+ ],
+ "changeDestinationsStrategy": "",
+ "changeMinimumSatoshis": 0,
+ "changeNumberOfDestinations": 0,
+ "changeSatoshis": 98,
+ "expiresIn": 0,
+ "fee": 0,
+ "feeUnit": {
+ "satoshis": 1,
+ "bytes": 1000
+ },
+ "fromUtxos": null,
+ "includeUtxos": null,
+ "inputs": [
+ {
+ "createdAt": "2024-11-05T07:30:14.219077Z",
+ "updatedAt": "2024-11-05T07:30:14.219077Z",
+ "deletedAt": null,
+ "metadata": null,
+ "transactionId": "3e0c5f6d-0dfc-462d-8a63-31b7a20d0c6b",
+ "outputIndex": 0,
+ "id": "203277ff-006a-4e48-bbe9-2f1b6fb9ddfd",
+ "xpubId": "4676a7d6-45f8-46b3-850b-68a9bb7642bc",
+ "satoshis": 100,
+ "scriptPubKey": "9d7eede4-00cd-47fd-ab3d-b0ae6d2ca6a6",
+ "type": "pubkeyhash",
+ "draftId": "f1ebe294-d921-4fb7-8b22-ed33e090e7ea",
+ "reservedAt": "2024-11-05T07:30:14.207287Z",
+ "spendingTxId": "",
+ "transaction": null,
+ "destination": {
+ "createdAt": "2024-11-05T07:30:14.219077Z",
+ "updatedAt": "2024-11-05T07:30:14.219077Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "paymail_request": "CreateP2PDestinationResponse",
+ "reference_id": "1a461311db24115cd5e0525f8c9b5613",
+ "satoshis": 100,
+ "user_agent": "node-fetch"
+ },
+ "id": "bc22a0b9-d91c-4d0b-a7e4-8ea2d37e42db",
+ "xpubId": "325b1440-3af4-4a65-bf90-d88ed978948b",
+ "lockingScript": "e459d941-d820-4663-a5d8-6a12457825e9",
+ "type": "pubkeyhash",
+ "chain": 0,
+ "num": 0,
+ "paymailExternalDerivationNum": 3,
+ "address": "6e4f50b1-356b-4453-a83e-2f412f328c25",
+ "draftId": ""
+ }
+ }
+ ],
+ "outputs": [
+ {
+ "paymailP4": {
+ "alias": "john.doe.test4",
+ "domain": "john.doe.test.4chain.space",
+ "fromPaymail": "from@domain.com",
+ "receiveEndpoint": "https://john.doe.test.4chain.space:443/v1/bsvalias/beef/{alias}@{domain.tld}",
+ "referenceId": "bdac6a12ec7f31feb5ae426e28c9ddfa",
+ "resolutionType": "p2p"
+ },
+ "satoshis": 1,
+ "script": "",
+ "scripts": [
+ {
+ "address": "18p1xtQQeaVVpsxrSiRUhUKMyR5jPEvAhY",
+ "satoshis": 1,
+ "script": "45a858f8-c645-48c3-bff0-f776d8d8452d",
+ "scriptType": "pubkeyhash"
+ }
+ ],
+ "to": "john.doe.test4@john.doe.test.4chain.space",
+ "useForChange": false
+ },
+ {
+ "satoshis": 98,
+ "script": "",
+ "scripts": [
+ {
+ "address": "19a5857d-3eb9-43f8-b240-c29c05909fdc",
+ "satoshis": 98,
+ "script": "cca457ab-2277-457b-bf53-17face515f5c",
+ "scriptType": "pubkeyhash"
+ }
+ ],
+ "to": "b1e97d9c-e1e5-4120-b0f1-0363693b1959",
+ "useForChange": false
+ }
+ ],
+ "sendAllTo": null,
+ "sync": null
+ },
+ "status": "",
+ "finalTxId": ""
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/post_transaction_record_201.json b/internal/api/v1/user/transactions/transactionstest/post_transaction_record_201.json
new file mode 100644
index 00000000..f4c903d4
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/post_transaction_record_201.json
@@ -0,0 +1,30 @@
+{
+ "blockHash": "47758f612c6bf5b454bcd642fe8031f6",
+ "blockHeight": 1024,
+ "createdAt": "2024-10-07T14:03:26.736816Z",
+ "direction": "outgoing",
+ "draftId": "d3fb66d6-6e3b-4a1f-aa80-dda848079663",
+ "fee": 1,
+ "hex": "fda8f356-615e-4b4c-a3c8-53a47531a446",
+ "id": "fdad0324-1185-4a54-8eae-f0c8858fa3ce",
+ "metadata": {
+ "key": "value",
+ "key2": "value2"
+ },
+ "numberOfInputs": 3,
+ "numberOfOutputs": 2,
+ "outputValue": 50,
+ "outputs": {
+ "92640954841510a9d95f7737a43075f22ebf7255976549de4c52e8f3faf57470": -51,
+ "9d07977d2fc14402426288a6010b4cdf7d91b61461acfb75af050b209d2d07ba": 50
+ },
+ "status": "MINED",
+ "totalValue": 51,
+ "updatedAt": "2024-10-07T14:03:26.736816Z",
+ "xpubInIds": [
+ "e2be970c-a867-4e65-b141-7f2aafd44a42"
+ ],
+ "xpubOutIds": [
+ "475e5e90-a117-46b6-b9e5-6983f2721b19"
+ ]
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_draft_with_hex_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_draft_with_hex_200.json
new file mode 100644
index 00000000..a03423ce
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/transaction_draft_with_hex_200.json
@@ -0,0 +1,110 @@
+{
+ "createdAt": "2024-12-02T12:04:33.855018Z",
+ "updatedAt": "2024-12-02T13:04:33.855036+01:00",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5",
+ "hex": "01000000014c037d55e72d2ee6a95ff67bd758c4cee9c7545bb4d72ba77584152fcfa070120100000000ffffffff0200000000000000000e006a0568656c6c6f05776f726c6408000000000000001976a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac00000000",
+ "xpubId": "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ "expiresAt": "2024-12-02T12:04:53.840989Z",
+ "configuration": {
+ "changeDestinations": [
+ {
+ "createdAt": "2024-12-02T12:04:33.853019Z",
+ "updatedAt": "2024-12-02T13:04:33.853035+01:00",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "872a51f9eed774e7e5051cec19db192783521b5a9e0d4d814d46bdce338a32dc",
+ "xpubId": "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ "lockingScript": "76a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac",
+ "type": "pubkeyhash",
+ "chain": 1,
+ "num": 18,
+ "paymailExternalDerivationNum": null,
+ "address": "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ "draftId": "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5"
+ }
+ ],
+ "changeDestinationsStrategy": "",
+ "changeMinimumSatoshis": 0,
+ "changeNumberOfDestinations": 0,
+ "changeSatoshis": 8,
+ "expiresIn": 0,
+ "fee": 0,
+ "feeUnit": {
+ "satoshis": 1,
+ "bytes": 1000
+ },
+ "fromUtxos": null,
+ "includeUtxos": null,
+ "inputs": [
+ {
+ "createdAt": "2024-11-29T23:13:54.0229Z",
+ "updatedAt": "2024-12-02T12:04:33.847931Z",
+ "deletedAt": null,
+ "metadata": null,
+ "transactionId": "1270a0cf2f158475a72bd7b45b54c7e9cec458d77bf65fa9e62e2de7557d034c",
+ "outputIndex": 1,
+ "id": "062bc7c22bf1f08e1716169e73f647e8ae2175ee3e8479de1bbc052eaa514d1d",
+ "xpubId": "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ "satoshis": 9,
+ "scriptPubKey": "76a9146637345046fd4d78a9ce187370db0ab7c15dd10488ac",
+ "type": "pubkeyhash",
+ "draftId": "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5",
+ "reservedAt": "2024-12-02T12:04:33.846479Z",
+ "spendingTxId": "",
+ "transaction": null,
+ "destination": {
+ "createdAt": "2024-11-29T23:13:54.000014Z",
+ "updatedAt": "2024-11-30T00:13:54.000029+01:00",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "886d2ac60ac7fa630ad68954d6eb865314c484b4418e2469d48e4170dec7771f",
+ "xpubId": "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ "lockingScript": "76a9146637345046fd4d78a9ce187370db0ab7c15dd10488ac",
+ "type": "pubkeyhash",
+ "chain": 1,
+ "num": 16,
+ "paymailExternalDerivationNum": null,
+ "address": "1AKU4EU46p38GWhaEcvuLL2UK23Fv14cwn",
+ "draftId": "254686b74d37878852b41503cf33604d5f6ba692705df08a855dc4d926b47251"
+ }
+ }
+ ],
+ "outputs": [
+ {
+ "opReturn": {
+ "stringParts": ["hello", "world"]
+ },
+ "satoshis": 0,
+ "script": "",
+ "scripts": [
+ {
+ "script": "006a0568656c6c6f05776f726c64",
+ "scriptType": "nulldata"
+ }
+ ],
+ "to": "",
+ "useForChange": false
+ },
+ {
+ "satoshis": 8,
+ "script": "",
+ "scripts": [
+ {
+ "address": "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ "satoshis": 8,
+ "script": "76a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac",
+ "scriptType": "pubkeyhash"
+ }
+ ],
+ "to": "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ "useForChange": false
+ }
+ ],
+ "sendAllTo": null,
+ "sync": null
+ },
+ "status": "",
+ "finalTxId": ""
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_send_to_recipients_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_send_to_recipients_200.json
new file mode 100644
index 00000000..637c810d
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/transaction_send_to_recipients_200.json
@@ -0,0 +1,24 @@
+{
+ "createdAt": "2024-12-03T16:10:48.551774Z",
+ "updatedAt": "2024-12-03T16:10:49.080876Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "a4f86cdfefc3339bd3bd7861ad642feab05798f8a31cd67f81aec3c8c87083e0",
+ "hex": "01000000017c8b38da58d766c75d94ca65f919723651090ed03e89a6cbc31ab7b87923d6ea010000006b483045022100b2e654169dda17a68c74b24d21e7b2e0dfef7fccdad9ab1e1ec87d2cab910e1d02206e247df32c4fe845af61001c8ec1dda718d737021df27c1d3f2e47d9fe76dd4241210265332864a94ed4c82bf3dacafbb828479b0a7fd0a73e62f60f6224dbf1504261ffffffff0200000000000000000e006a0568656c6c6f05776f726c6407000000000000001976a91464d00b8045c9e432b469f762b7e5beac2ef5a20c88ac00000000",
+ "xpubInIds": [
+ "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973"
+ ],
+ "xpubOutIds": [
+ "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973"
+ ],
+ "blockHash": "47758f612c6bf5b454bcd642fe8031f6",
+ "blockHeight": 1024,
+ "fee": 1,
+ "numberOfInputs": 1,
+ "numberOfOutputs": 2,
+ "draftId": "4b0571c11a8a96d5af85bfbc32b98d4de6f3cc788afa2a6cd028ef9b69acc779",
+ "totalValue": 0,
+ "outputValue": -1,
+ "status": "BROADCASTED",
+ "direction": "outgoing"
+}
diff --git a/internal/api/v1/user/transactions/transactionstest/transactions_api_fixtures.go b/internal/api/v1/user/transactions/transactionstest/transactions_api_fixtures.go
new file mode 100644
index 00000000..e8fa9a54
--- /dev/null
+++ b/internal/api/v1/user/transactions/transactionstest/transactions_api_fixtures.go
@@ -0,0 +1,435 @@
+package transactionstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedDraftTransactionWithWrongInputs(t *testing.T) *response.DraftTransaction {
+ draftWithWrongInputs := ExpectedDraftTransactionWithHex(t)
+ draftWithWrongInputs.Configuration.Inputs[0].TransactionID = "wrong-input-transaction-id"
+ return draftWithWrongInputs
+}
+
+func ExpectedDraftTransactionWithWrongLockingScript(t *testing.T) *response.DraftTransaction {
+ draftWithWrongLockingScript := ExpectedDraftTransactionWithHex(t)
+ draftWithWrongLockingScript.Configuration.Inputs[0].Destination.LockingScript = "wrong-locking-script"
+ return draftWithWrongLockingScript
+}
+
+func ExpectedDraftTransactionWithWrongHex(t *testing.T) *response.DraftTransaction {
+ draftWithWrongHex := ExpectedDraftTransactionWithHex(t)
+ draftWithWrongHex.Hex = "wrong-hex"
+ return draftWithWrongHex
+}
+
+func ExpectedDraftTransactionWithHex(t *testing.T) *response.DraftTransaction {
+ return &response.DraftTransaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-12-02T12:04:33.855018Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-02T13:04:33.855036+01:00"),
+ },
+ ID: "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5",
+ Hex: "01000000014c037d55e72d2ee6a95ff67bd758c4cee9c7545bb4d72ba77584152fcfa070120100000000ffffffff0200000000000000000e006a0568656c6c6f05776f726c6408000000000000001976a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac00000000",
+ XpubID: "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ ExpiresAt: testutils.ParseTime(t, "2024-12-02T12:04:53.840989Z"),
+ Configuration: response.TransactionConfig{
+ ChangeSatoshis: 8,
+ ChangeDestinations: []*response.Destination{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-12-02T12:04:33.853019Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-02T13:04:33.853035+01:00"),
+ },
+ ID: "872a51f9eed774e7e5051cec19db192783521b5a9e0d4d814d46bdce338a32dc",
+ XpubID: "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ LockingScript: "76a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac",
+ Type: "pubkeyhash",
+ Chain: 1,
+ Num: 18,
+ Address: "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ DraftID: "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5",
+ },
+ },
+ FeeUnit: &response.FeeUnit{
+ Satoshis: 1,
+ Bytes: 1000,
+ },
+ Inputs: []*response.TransactionInput{
+ {
+ Utxo: response.Utxo{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-29T23:13:54.0229Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-02T12:04:33.847931Z"),
+ },
+ UtxoPointer: response.UtxoPointer{
+ TransactionID: "1270a0cf2f158475a72bd7b45b54c7e9cec458d77bf65fa9e62e2de7557d034c",
+ },
+ ID: "062bc7c22bf1f08e1716169e73f647e8ae2175ee3e8479de1bbc052eaa514d1d",
+ XpubID: "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ Satoshis: 9,
+ ScriptPubKey: "76a9146637345046fd4d78a9ce187370db0ab7c15dd10488ac",
+ Type: "pubkeyhash",
+ DraftID: "de3b8ef7041b2a528bc47ecdb3b87b06b61407fe24789bc02f9d49bfc234b4d5",
+ ReservedAt: testutils.ParseTime(t, "2024-12-02T12:04:33.846479Z"),
+ },
+ Destination: response.Destination{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-29T23:13:54.000014Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-30T00:13:54.000029+01:00"),
+ },
+ ID: "886d2ac60ac7fa630ad68954d6eb865314c484b4418e2469d48e4170dec7771f",
+ XpubID: "55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973",
+ LockingScript: "76a9146637345046fd4d78a9ce187370db0ab7c15dd10488ac",
+ Type: "pubkeyhash",
+ Chain: 1,
+ Num: 16,
+ Address: "1AKU4EU46p38GWhaEcvuLL2UK23Fv14cwn",
+ DraftID: "254686b74d37878852b41503cf33604d5f6ba692705df08a855dc4d926b47251",
+ },
+ },
+ },
+ Outputs: []*response.TransactionOutput{
+ {
+ PaymailP4: nil,
+ Satoshis: 0,
+ Scripts: []*response.ScriptOutput{
+ {
+ Script: "006a0568656c6c6f05776f726c64",
+ ScriptType: "nulldata",
+ },
+ },
+ UseForChange: false,
+ },
+ {
+ Satoshis: 8,
+ Scripts: []*response.ScriptOutput{
+ {
+ Address: "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ Satoshis: 8,
+ Script: "76a914702cef80a7039a1aebb70dc05ce1e439646fa33788ac",
+ ScriptType: "pubkeyhash",
+ },
+ },
+ To: "1BE8WfQkDDYE3zEgxBdRNuAxsnHkDcuPdT",
+ UseForChange: false,
+ },
+ },
+ },
+ }
+}
+
+func ExpectedSendToRecipientsTransaction(t *testing.T) *response.Transaction {
+ return &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-12-03T16:10:48.551774Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-12-03T16:10:49.080876Z"),
+ },
+ ID: "a4f86cdfefc3339bd3bd7861ad642feab05798f8a31cd67f81aec3c8c87083e0",
+ Hex: "01000000017c8b38da58d766c75d94ca65f919723651090ed03e89a6cbc31ab7b87923d6ea010000006b483045022100b2e654169dda17a68c74b24d21e7b2e0dfef7fccdad9ab1e1ec87d2cab910e1d02206e247df32c4fe845af61001c8ec1dda718d737021df27c1d3f2e47d9fe76dd4241210265332864a94ed4c82bf3dacafbb828479b0a7fd0a73e62f60f6224dbf1504261ffffffff0200000000000000000e006a0568656c6c6f05776f726c6407000000000000001976a91464d00b8045c9e432b469f762b7e5beac2ef5a20c88ac00000000",
+ XpubInIDs: []string{"55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973"},
+ XpubOutIDs: []string{"55e5aeae101bf7dc49db2abfccfab9fb5f56a6b594fdcc87e5f5a94bfe94b973"},
+ BlockHash: "47758f612c6bf5b454bcd642fe8031f6",
+ BlockHeight: 1024,
+ Fee: 1,
+ NumberOfInputs: 1,
+ NumberOfOutputs: 2,
+ DraftID: "4b0571c11a8a96d5af85bfbc32b98d4de6f3cc788afa2a6cd028ef9b69acc779",
+ TotalValue: 0,
+ OutputValue: -1,
+ Status: "BROADCASTED",
+ TransactionDirection: "outgoing",
+ }
+}
+
+func ExpectedDraftTransaction(t *testing.T) *response.DraftTransaction {
+ return &response.DraftTransaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ Metadata: map[string]interface{}{
+ "receiver": "john.doe.test4@john.doe.test.4chain.space",
+ "sender": "john.doe.test4@john.doe.test.4chain.space",
+ },
+ },
+ ID: "36be741b-31c7-4aed-8840-5e5b2eafeb41",
+ Hex: "c959fdb6-f438-4ef9-aef9-92a1852885ef",
+ XpubID: "3f0a90d3-4f8b-45f6-81e4-9858fa47ecc0",
+ ExpiresAt: testutils.ParseTime(t, "2024-11-05T07:30:27.372912Z"),
+ Configuration: response.TransactionConfig{
+ ChangeSatoshis: 98,
+ ChangeDestinations: []*response.Destination{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ },
+ ID: "c86dd8f4-316f-4d71-be00-7bd1a38079e4",
+ XpubID: "d6884260-1624-415b-8625-652a59345ead",
+ LockingScript: "189593db-0048-4fb7-80da-b69bce8fbf78",
+ Type: "pubkeyhash",
+ Chain: 1,
+ Num: 5,
+ Address: "3f96ea59-ac83-476e-a0ea-f0d668086081",
+ DraftID: "fc60742e-92b5-4a98-90a7-422d89879494",
+ },
+ },
+ FeeUnit: &response.FeeUnit{
+ Satoshis: 1,
+ Bytes: 1000,
+ },
+ Inputs: []*response.TransactionInput{
+ {
+ Utxo: response.Utxo{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ },
+ UtxoPointer: response.UtxoPointer{
+ TransactionID: "3e0c5f6d-0dfc-462d-8a63-31b7a20d0c6b",
+ },
+ ID: "203277ff-006a-4e48-bbe9-2f1b6fb9ddfd",
+ XpubID: "4676a7d6-45f8-46b3-850b-68a9bb7642bc",
+ Satoshis: 100,
+ ScriptPubKey: "9d7eede4-00cd-47fd-ab3d-b0ae6d2ca6a6",
+ Type: "pubkeyhash",
+ DraftID: "f1ebe294-d921-4fb7-8b22-ed33e090e7ea",
+ ReservedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.207287Z"),
+ },
+ Destination: response.Destination{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-05T07:30:14.219077Z"),
+ Metadata: map[string]interface{}{
+ "domain": "john.doe.test.4chain.space",
+ "ip_address": "127.0.0.1",
+ "paymail_request": "CreateP2PDestinationResponse",
+ "reference_id": "1a461311db24115cd5e0525f8c9b5613",
+ "satoshis": float64(100),
+ "user_agent": "node-fetch",
+ },
+ },
+ ID: "bc22a0b9-d91c-4d0b-a7e4-8ea2d37e42db",
+ XpubID: "325b1440-3af4-4a65-bf90-d88ed978948b",
+ LockingScript: "e459d941-d820-4663-a5d8-6a12457825e9",
+ Type: "pubkeyhash",
+ Chain: 0,
+ Num: 0,
+ PaymailExternalDerivationNum: testutils.Ptr(uint32(3)),
+ Address: "6e4f50b1-356b-4453-a83e-2f412f328c25",
+ DraftID: "",
+ },
+ },
+ },
+ Outputs: []*response.TransactionOutput{
+ {
+ PaymailP4: &response.PaymailP4{
+ Alias: "john.doe.test4",
+ Domain: "john.doe.test.4chain.space",
+ FromPaymail: "from@domain.com",
+ ReceiveEndpoint: "https://john.doe.test.4chain.space:443/v1/bsvalias/beef/{alias}@{domain.tld}",
+ ReferenceID: "bdac6a12ec7f31feb5ae426e28c9ddfa",
+ ResolutionType: "p2p",
+ },
+ Satoshis: 1,
+ Scripts: []*response.ScriptOutput{
+ {
+ Address: "18p1xtQQeaVVpsxrSiRUhUKMyR5jPEvAhY",
+ Satoshis: 1,
+ Script: "45a858f8-c645-48c3-bff0-f776d8d8452d",
+ ScriptType: "pubkeyhash",
+ },
+ },
+ To: "john.doe.test4@john.doe.test.4chain.space",
+ UseForChange: false,
+ },
+ {
+ Satoshis: 98,
+ Scripts: []*response.ScriptOutput{
+ {
+ Address: "19a5857d-3eb9-43f8-b240-c29c05909fdc",
+ Satoshis: 98,
+ Script: "cca457ab-2277-457b-bf53-17face515f5c",
+ ScriptType: "pubkeyhash",
+ },
+ },
+ To: "b1e97d9c-e1e5-4120-b0f1-0363693b1959",
+ UseForChange: false,
+ },
+ },
+ },
+ }
+}
+
+func ExpectedRecordTransaction(t *testing.T) *response.Transaction {
+ return &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ Metadata: map[string]interface{}{
+ "key": "value",
+ "key2": "value2",
+ },
+ },
+ ID: "fdad0324-1185-4a54-8eae-f0c8858fa3ce",
+ Hex: "fda8f356-615e-4b4c-a3c8-53a47531a446",
+ XpubInIDs: []string{"e2be970c-a867-4e65-b141-7f2aafd44a42"},
+ XpubOutIDs: []string{"475e5e90-a117-46b6-b9e5-6983f2721b19"},
+ BlockHash: "47758f612c6bf5b454bcd642fe8031f6",
+ BlockHeight: 1024,
+ Fee: 1,
+ NumberOfInputs: 3,
+ NumberOfOutputs: 2,
+ DraftID: "d3fb66d6-6e3b-4a1f-aa80-dda848079663",
+ TotalValue: 51,
+ OutputValue: 50,
+ Outputs: map[string]int64{
+ "92640954841510a9d95f7737a43075f22ebf7255976549de4c52e8f3faf57470": -51,
+ "9d07977d2fc14402426288a6010b4cdf7d91b61461acfb75af050b209d2d07ba": 50,
+ },
+ Status: "MINED",
+ TransactionDirection: "outgoing",
+ }
+}
+
+func ExpectedTransactionWithUpdatedMetadata(t *testing.T) *response.Transaction {
+ return &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ Metadata: map[string]any{
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "example_key2": "example_key20_val",
+ "ip_address": "127.0.0.01",
+ "user_agent": "node-fetch",
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd",
+ "sender": "john.doe@handcash.io",
+ },
+ },
+ },
+ ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ Hex: "283b1c6deb6d6263b3cec7a4701d46d3",
+ XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"},
+ BlockHash: "47758f612c6bf5b454bcd642fe8031f6",
+ BlockHeight: 512,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 311,
+ OutputValue: 100,
+ Status: "MINED",
+ TransactionDirection: "incoming",
+ }
+}
+
+func ExpectedTransaction(t *testing.T) *response.Transaction {
+ return &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ Metadata: map[string]any{
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "ip_address": "127.0.0.01",
+ "user_agent": "node-fetch",
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd",
+ "sender": "john.doe@handcash.io",
+ },
+ },
+ },
+ ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ Hex: "283b1c6deb6d6263b3cec7a4701d46d3",
+ XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"},
+ BlockHash: "47758f612c6bf5b454bcd642fe8031f6",
+ BlockHeight: 512,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 311,
+ OutputValue: 100,
+ Status: "MINED",
+ TransactionDirection: "incoming",
+ }
+}
+
+func ExpectedTransactionsPage(t *testing.T) *response.PageModel[response.Transaction] {
+ return &response.PageModel[response.Transaction]{
+ Content: []*response.Transaction{
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ Metadata: map[string]any{
+ "domain": "john.doe.test.4chain.space",
+ "example_key1": "example_key10_val",
+ "ip_address": "127.0.0.01",
+ "user_agent": "node-fetch",
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065",
+ "sender": "john.doe@handcash.io",
+ },
+ },
+ },
+ ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e",
+ Hex: "283b1c6deb6d6263b3cec7a4701d46d3",
+ XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"},
+ BlockHash: "47758f612c6bf5b454bcd642fe8031f6",
+ BlockHeight: 512,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 311,
+ OutputValue: 100,
+ Status: "MINED",
+ TransactionDirection: "incoming",
+ },
+ {
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-10-07T14:03:26.736816Z"),
+ Metadata: map[string]any{
+ "domain": "jane.doe.test.4chain.space",
+ "example_key101": "example_key101_val",
+ "ip_address": "127.0.0.01",
+ "user_agent": "node-fetch",
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa",
+ "sender": "jane.doe@handcash.io",
+ },
+ },
+ },
+ ID: "1c110e11-c23a-51e5-a7e7-99c12b01233e",
+ Hex: "283b1c7deb7d7773b3cec7a8801d47d2",
+ XpubOutIDs: []string{"2c8a1a1d-ea5f-5f04-b890-92418b2d411d"},
+ BlockHash: "56659f622c6bf5b554bcd742fe8132f9",
+ BlockHeight: 1024,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 500,
+ OutputValue: 200,
+ Status: "MINED",
+ TransactionDirection: "incoming",
+ },
+ },
+ Page: response.PageDescription{
+ Size: 2,
+ Number: 2,
+ TotalElements: 2,
+ TotalPages: 1,
+ },
+ }
+}
diff --git a/internal/api/v1/user/utxos/utxos_api.go b/internal/api/v1/user/utxos/utxos_api.go
new file mode 100644
index 00000000..85ff1438
--- /dev/null
+++ b/internal/api/v1/user/utxos/utxos_api.go
@@ -0,0 +1,55 @@
+package utxos
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/queryparams"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/utxos"
+ api = "User UTXOs API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) UTXOs(ctx context.Context, opts ...queries.QueryOption[filter.UtxoFilter]) (*queries.UtxosPage, error) {
+ query := queries.NewQuery(opts...)
+ parser, err := queryparams.NewQueryParser(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize query parser: %w", err)
+ }
+
+ params, err := parser.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build utxo query params: %w", err)
+ }
+
+ var result queries.UtxosPage
+ _, err = a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetQueryParams(params.ParseToMap()).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+}
diff --git a/internal/api/v1/user/utxos/utxos_api_test.go b/internal/api/v1/user/utxos/utxos_api_test.go
new file mode 100644
index 00000000..8ec5d759
--- /dev/null
+++ b/internal/api/v1/user/utxos/utxos_api_test.go
@@ -0,0 +1,72 @@
+package utxos_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const utxosURL = "/api/v1/utxos"
+
+func TestUTXOAPI_UTXOs(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *queries.UtxosPage
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/utxos response: 200": {
+ expectedResponse: utxostest.ExpectedUtxosPage(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("utxostest/get_utxos_200.json"),
+ },
+ "HTTP GET /api/v1/utxos response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/utxos response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/utxos str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, utxosURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ opts := []queries.QueryOption[filter.UtxoFilter]{
+ queries.QueryWithPageFilter[filter.UtxoFilter](filter.Page{
+ Number: 1,
+ Size: 1,
+ Sort: "asc",
+ SortBy: "key",
+ }),
+ queries.QueryWithFilter(filter.UtxoFilter{
+ ModelFilter: filter.ModelFilter{
+ IncludeDeleted: testutils.Ptr(true),
+ },
+ }),
+ }
+ params := "page=1&size=1&sort=asc&sortBy=key&includeDeleted=true"
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponderWithQuery(http.MethodGet, url, params, tc.responder)
+
+ // when:
+ got, err := wallet.UTXOs(context.Background(), opts...)
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/user/utxos/utxostest/get_utxos_200.json b/internal/api/v1/user/utxos/utxostest/get_utxos_200.json
new file mode 100644
index 00000000..d13b4723
--- /dev/null
+++ b/internal/api/v1/user/utxos/utxostest/get_utxos_200.json
@@ -0,0 +1,95 @@
+{
+ "content": [
+ {
+ "createdAt": "2024-11-12T11:31:07.728974Z",
+ "updatedAt": "2024-11-12T11:31:07.732139Z",
+ "deletedAt": null,
+ "metadata": null,
+ "transactionId": "f365f697-3db9-44fd-bd0d-ba8e94ca63f2",
+ "outputIndex": 0,
+ "id": "db9bdd87-432d-44e6-b08f-9c0abd0d90ef",
+ "xpubId": "0f8ff805-a282-48d6-be70-8b607deba5f1",
+ "satoshis": 100,
+ "scriptPubKey": "88ca49f2-816e-4a0b-b5c5-e5c574e2d292",
+ "type": "pubkeyhash",
+ "reservedAt": "0001-01-01T00:00:00Z",
+ "spendingTxId": "",
+ "transaction": {
+ "createdAt": "2024-11-12T11:31:07.72894Z",
+ "updatedAt": "2024-11-12T12:33:35.266758Z",
+ "deletedAt": null,
+ "metadata": {
+ "domain": "john.doe.test.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": {
+ "pubkey": "d90c6998-010a-466f-83d7-25c39188a1c5",
+ "sender": "john.doe@test.com"
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "81fbfb26-e648-463e-99ce-ade498774c8f",
+ "user_agent": "node-fetch"
+ },
+ "id": "ec943c46-bfa8-4764-820a-a604c8b6c890",
+ "hex": "d825088b-1f04-406a-b046-059bc0736b11",
+ "xpubInIds": null,
+ "xpubOutIds": [
+ "6e980e21-a8f8-4699-9d11-98aef96bdf98"
+ ],
+ "blockHash": "a7755931-eceb-473e-ab5b-6a6459948166",
+ "blockHeight": 1024,
+ "fee": 0,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 3,
+ "draftId": "",
+ "totalValue": 1305,
+ "status": "MINED",
+ "direction": "outgoing"
+ }
+ },
+ {
+ "createdAt": "2024-11-08T13:40:55.592Z",
+ "updatedAt": "2024-11-08T13:40:55.593441Z",
+ "deletedAt": null,
+ "metadata": null,
+ "transactionId": "54ed5bcb-a964-47af-892b-1054065c28a8",
+ "outputIndex": 1,
+ "id": "7ed4a935-6b62-4e83-9d97-7e9a7f9eab30",
+ "xpubId": "68019cf3-616c-4d14-b9bf-cd9486b63f4f",
+ "satoshis": 18,
+ "scriptPubKey": "5e63148d-f506-43fb-88c3-2d98491625da",
+ "type": "pubkeyhash",
+ "draftId": "",
+ "reservedAt": "0001-01-01T00:00:00Z",
+ "spendingTxId": "",
+ "transaction": {
+ "createdAt": "2024-11-08T13:40:55.591986Z",
+ "updatedAt": "2024-11-08T14:43:56.256571Z",
+ "deletedAt": null,
+ "metadata": null,
+ "id": "29b89717-f139-45ae-9848-f2d7415ea596",
+ "hex": "6a1c1ddb-f3c1-4491-98b4-9ce3eb016e60",
+ "xpubInIds": [
+ "32dfa8c9-82e3-4f49-8d33-ff7130e1cfae"
+ ],
+ "xpubOutIds": [
+ "b0559e5f-b4b5-416f-b1f8-116f19a89f30"
+ ],
+ "blockHash": "f90fb747-4cec-4e00-912a-582d46090d61",
+ "blockHeight": 2048,
+ "fee": 1,
+ "numberOfInputs": 2,
+ "numberOfOutputs": 2,
+ "draftId": "057a743c-4c97-444b-b6ac-8b4a757aee8c",
+ "totalValue": 0,
+ "status": "MINED",
+ "direction": "outgoing"
+ }
+ }
+ ],
+ "page": {
+ "size": 2,
+ "number": 1,
+ "totalElements": 9,
+ "totalPages": 5
+ }
+}
diff --git a/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go
new file mode 100644
index 00000000..aa306efd
--- /dev/null
+++ b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go
@@ -0,0 +1,101 @@
+package utxostest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedUtxosPage(t *testing.T) *queries.UtxosPage {
+ return &queries.UtxosPage{
+ Content: []*response.Utxo{
+ {
+ ID: "db9bdd87-432d-44e6-b08f-9c0abd0d90ef",
+ XpubID: "0f8ff805-a282-48d6-be70-8b607deba5f1",
+ Satoshis: 100,
+ ScriptPubKey: "88ca49f2-816e-4a0b-b5c5-e5c574e2d292",
+ Type: "pubkeyhash",
+ ReservedAt: testutils.ParseTime(t, "0001-01-01T00:00:00Z"),
+ UtxoPointer: response.UtxoPointer{
+ TransactionID: "f365f697-3db9-44fd-bd0d-ba8e94ca63f2",
+ OutputIndex: 0,
+ },
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-12T11:31:07.728974Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-12T11:31:07.732139Z"),
+ },
+ Transaction: &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-12T11:31:07.72894Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-12T12:33:35.266758Z"),
+
+ Metadata: map[string]any{
+ "domain": "john.doe.test.space",
+ "ip_address": "127.0.0.1",
+ "p2p_tx_metadata": map[string]any{
+ "pubkey": "d90c6998-010a-466f-83d7-25c39188a1c5",
+ "sender": "john.doe@test.com",
+ },
+ "paymail_request": "HandleReceivedP2pTransaction",
+ "reference_id": "81fbfb26-e648-463e-99ce-ade498774c8f",
+ "user_agent": "node-fetch",
+ },
+ },
+ ID: "ec943c46-bfa8-4764-820a-a604c8b6c890",
+ Hex: "d825088b-1f04-406a-b046-059bc0736b11",
+ XpubOutIDs: []string{"6e980e21-a8f8-4699-9d11-98aef96bdf98"},
+ BlockHash: "a7755931-eceb-473e-ab5b-6a6459948166",
+ BlockHeight: 1024,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 3,
+ TotalValue: 1305,
+ Status: "MINED",
+ TransactionDirection: "outgoing",
+ },
+ },
+ {
+ ID: "7ed4a935-6b62-4e83-9d97-7e9a7f9eab30",
+ XpubID: "68019cf3-616c-4d14-b9bf-cd9486b63f4f",
+ Satoshis: 18,
+ ScriptPubKey: "5e63148d-f506-43fb-88c3-2d98491625da",
+ Type: "pubkeyhash",
+ ReservedAt: testutils.ParseTime(t, "0001-01-01T00:00:00Z"),
+ UtxoPointer: response.UtxoPointer{
+ TransactionID: "54ed5bcb-a964-47af-892b-1054065c28a8",
+ OutputIndex: 1,
+ },
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-08T13:40:55.592Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-08T13:40:55.593441Z"),
+ },
+ Transaction: &response.Transaction{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-11-08T13:40:55.591986Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-08T14:43:56.256571Z"),
+ },
+ ID: "29b89717-f139-45ae-9848-f2d7415ea596",
+ Hex: "6a1c1ddb-f3c1-4491-98b4-9ce3eb016e60",
+ XpubInIDs: []string{"32dfa8c9-82e3-4f49-8d33-ff7130e1cfae"},
+ XpubOutIDs: []string{"b0559e5f-b4b5-416f-b1f8-116f19a89f30"},
+ BlockHash: "f90fb747-4cec-4e00-912a-582d46090d61",
+ BlockHeight: 2048,
+ Fee: 1,
+ NumberOfInputs: 2,
+ NumberOfOutputs: 2,
+ DraftID: "057a743c-4c97-444b-b6ac-8b4a757aee8c",
+ TotalValue: 0,
+ Status: "MINED",
+ TransactionDirection: "outgoing",
+ },
+ },
+ },
+ Page: response.PageDescription{
+ Size: 2,
+ Number: 1,
+ TotalElements: 9,
+ TotalPages: 5,
+ },
+ }
+}
diff --git a/internal/api/v1/user/xpubs/xpub_api.go b/internal/api/v1/user/xpubs/xpub_api.go
new file mode 100644
index 00000000..21d55983
--- /dev/null
+++ b/internal/api/v1/user/xpubs/xpub_api.go
@@ -0,0 +1,57 @@
+package xpubs
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+const (
+ route = "api/v1/users/current"
+ api = "User XPubs API"
+)
+
+type API struct {
+ url *url.URL
+ httpClient *resty.Client
+}
+
+func (a *API) XPub(ctx context.Context) (*response.Xpub, error) {
+ var result response.Xpub
+ _, err := a.httpClient.
+ R().
+ SetContext(ctx).
+ SetResult(&result).
+ Get(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func (a *API) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) {
+ var result response.Xpub
+ _, err := a.httpClient.R().
+ SetContext(ctx).
+ SetResult(&result).
+ SetBody(cmd).
+ Patch(a.url.String())
+ if err != nil {
+ return nil, fmt.Errorf("HTTP response failure: %w", err)
+ }
+
+ return &result, nil
+}
+
+func NewAPI(url *url.URL, httpClient *resty.Client) *API {
+ return &API{
+ url: url.JoinPath(route),
+ httpClient: httpClient,
+ }
+
+}
diff --git a/internal/api/v1/user/xpubs/xpub_api_test.go b/internal/api/v1/user/xpubs/xpub_api_test.go
new file mode 100644
index 00000000..93f31b6b
--- /dev/null
+++ b/internal/api/v1/user/xpubs/xpub_api_test.go
@@ -0,0 +1,101 @@
+package xpubs_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/xpubs/xpubstest"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+const xpubsURL = "/api/v1/users/current"
+
+func TestXPubAPI_UpdateXPubMetadata(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Xpub
+ expectedErr error
+ }{
+ "HTTP PATCH /api/v1/users/current response: 200": {
+ expectedResponse: xpubstest.ExpectedUpdatedXPubMetadata(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("xpubstest/patch_xpub_metadata_200.json"),
+ },
+ "HTTP PATCH /api/v1/users/current response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP PATCH /api/v1/users/current response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP PATCH /api/v1/users/current str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, xpubsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodPatch, url, tc.responder)
+
+ // when:
+ got, err := wallet.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{
+ Metadata: map[string]any{"example_key": "example_value"},
+ })
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResponse, got)
+ })
+ }
+}
+
+func TestXPubAPI_XPub(t *testing.T) {
+ tests := map[string]struct {
+ responder httpmock.Responder
+ expectedResponse *response.Xpub
+ expectedErr error
+ }{
+ "HTTP GET /api/v1/users/current response: 200": {
+ expectedResponse: xpubstest.ExpectedUserXPub(t),
+ responder: testutils.NewJSONFileResponderWithStatusOK("xpubstest/get_xpub_200.json"),
+ },
+ "HTTP GET /api/v1/users/current response: 400": {
+ expectedErr: testutils.NewBadRequestSPVError(),
+ responder: testutils.NewBadRequestSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/users/current response: 500": {
+ expectedErr: testutils.NewInternalServerSPVError(),
+ responder: testutils.NewInternalServerSPVErrorResponder(),
+ },
+ "HTTP GET /api/v1/users/current str response: 500": {
+ expectedErr: errors.ErrUnrecognizedAPIResponse,
+ responder: testutils.NewInternalServerSPVErrorStringResponder("unexpected internal server failure"),
+ },
+ }
+
+ url := testutils.FullAPIURL(t, xpubsURL)
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // given:
+ wallet, transport := testutils.GivenSPVUserAPI(t)
+ transport.RegisterResponder(http.MethodGet, url, tc.responder)
+
+ // when:
+ got, err := wallet.XPub(context.Background())
+
+ // then:
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.EqualValues(t, tc.expectedResponse, got)
+ })
+ }
+}
diff --git a/internal/api/v1/user/xpubs/xpubstest/get_xpub_200.json b/internal/api/v1/user/xpubs/xpubstest/get_xpub_200.json
new file mode 100644
index 00000000..a9f13bfc
--- /dev/null
+++ b/internal/api/v1/user/xpubs/xpubstest/get_xpub_200.json
@@ -0,0 +1,14 @@
+{
+ "createdAt": "2024-10-07T13:39:07.886862Z",
+ "updatedAt": "2024-11-12T11:31:07.741621Z",
+ "deletedAt": null,
+ "metadata": {
+ "metadata": {
+ "key": "value"
+ }
+ },
+ "id": "af64633f-b2ce-441e-9d61-acda0884eb53",
+ "currentBalance": 315,
+ "nextInternalNum": 13,
+ "nextExternalNum": 2
+ }
diff --git a/internal/api/v1/user/xpubs/xpubstest/patch_xpub_metadata_200.json b/internal/api/v1/user/xpubs/xpubstest/patch_xpub_metadata_200.json
new file mode 100644
index 00000000..a83c12fe
--- /dev/null
+++ b/internal/api/v1/user/xpubs/xpubstest/patch_xpub_metadata_200.json
@@ -0,0 +1,14 @@
+{
+ "createdAt": "2024-10-07T13:39:07.886862Z",
+ "updatedAt": "2024-11-13T11:41:56.115402Z",
+ "deletedAt": null,
+ "metadata": {
+ "metadata": {
+ "key": "value"
+ }
+ },
+ "id": "1356cc11-8164-4364-afa4-59f096a79eb5",
+ "currentBalance": 315,
+ "nextInternalNum": 13,
+ "nextExternalNum": 2
+ }
diff --git a/internal/api/v1/user/xpubs/xpubstest/xpub_api_fixtures.go b/internal/api/v1/user/xpubs/xpubstest/xpub_api_fixtures.go
new file mode 100644
index 00000000..18b5ebb6
--- /dev/null
+++ b/internal/api/v1/user/xpubs/xpubstest/xpub_api_fixtures.go
@@ -0,0 +1,44 @@
+package xpubstest
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+func ExpectedUpdatedXPubMetadata(t *testing.T) *response.Xpub {
+ return &response.Xpub{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T13:39:07.886862Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-13T11:41:56.115402Z"),
+ Metadata: map[string]any{
+ "metadata": map[string]any{
+ "key": "value",
+ },
+ },
+ },
+ ID: "1356cc11-8164-4364-afa4-59f096a79eb5",
+ CurrentBalance: 315,
+ NextInternalNum: 13,
+ NextExternalNum: 2,
+ }
+}
+
+func ExpectedUserXPub(t *testing.T) *response.Xpub {
+ return &response.Xpub{
+ Model: response.Model{
+ CreatedAt: testutils.ParseTime(t, "2024-10-07T13:39:07.886862Z"),
+ UpdatedAt: testutils.ParseTime(t, "2024-11-12T11:31:07.741621Z"),
+ Metadata: map[string]any{
+ "metadata": map[string]any{
+ "key": "value",
+ },
+ },
+ },
+ ID: "af64633f-b2ce-441e-9d61-acda0884eb53",
+ CurrentBalance: 315,
+ NextInternalNum: 13,
+ NextExternalNum: 2,
+ }
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 00000000..5aa288fe
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,108 @@
+package auth
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "time"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ bsm "github.com/bitcoin-sv/go-sdk/compat/bsm"
+ ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil"
+ "github.com/bitcoin-sv/spv-wallet/models"
+)
+
+func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error {
+ // Create the signature
+ authData, err := createSignature(xPriv, bodyString)
+ if err != nil {
+ return fmt.Errorf("failed to create signature: %w", err)
+ }
+
+ // Set the auth header
+ header.Set(models.AuthHeader, authData.XPub)
+ setSignatureHeaders(header, authData)
+ return nil
+}
+
+func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *models.AuthPayload, err error) {
+ // Get the xPub
+ payload = new(models.AuthPayload)
+ if payload.XPub, err = bip32.GetExtendedPublicKey(xPriv); err != nil { // Should never error if key is correct
+ return
+ }
+
+ // auth_nonce is a random unique string to seed the signing message
+ // this can be checked server side to make sure the request is not being replayed
+ if payload.AuthNonce, err = cryptoutil.RandomHex(32); err != nil { // Should never error if key is correct
+ return
+ }
+
+ // Derive the address for signing
+ var key *bip32.ExtendedKey
+ if key, err = cryptoutil.DeriveChildKeyFromHex(xPriv, payload.AuthNonce); err != nil {
+ return
+ }
+
+ var privateKey *ec.PrivateKey
+ if privateKey, err = bip32.GetPrivateKeyFromHDKey(key); err != nil {
+ return // Should never error if key is correct
+ }
+ return createSignatureCommon(payload, bodyString, privateKey)
+}
+
+func createSignatureCommon(payload *models.AuthPayload, bodyString string, privateKey *ec.PrivateKey) (*models.AuthPayload, error) {
+ // Create the auth header hash
+ payload.AuthHash = cryptoutil.Hash(bodyString)
+ // auth_time is the current time and makes sure a request can not be sent after 30 secs
+ payload.AuthTime = time.Now().UnixMilli()
+
+ key := payload.XPub
+ if key == "" && payload.AccessKey != "" {
+ key = payload.AccessKey
+ }
+ // Signature, using bitcoin signMessage
+ sigBytes, err := bsm.SignMessage(privateKey, getSigningMessage(key, payload))
+ if err != nil {
+ return nil, fmt.Errorf("failed to sign message, %w", err)
+ }
+
+ payload.Signature = base64.StdEncoding.EncodeToString(sigBytes)
+ return payload, nil
+}
+
+func getSigningMessage(xPub string, auth *models.AuthPayload) []byte {
+ message := fmt.Sprintf("%s%s%s%d", xPub, auth.AuthHash, auth.AuthNonce, auth.AuthTime)
+ return []byte(message)
+}
+
+func setSignatureHeaders(header *http.Header, authData *models.AuthPayload) {
+ header.Set(models.AuthHeaderHash, authData.AuthHash)
+ header.Set(models.AuthHeaderNonce, authData.AuthNonce)
+ header.Set(models.AuthHeaderTime, fmt.Sprintf("%d", authData.AuthTime))
+ header.Set(models.AuthSignature, authData.Signature)
+}
+
+func createSignatureAccessKey(privateKeyHex, bodyString string) (payload *models.AuthPayload, err error) {
+ privateKey, err := ec.PrivateKeyFromHex(privateKeyHex)
+ if err != nil {
+ return
+ }
+
+ publicKey := privateKey.PubKey()
+
+ // Get the AccessKey
+ payload = new(models.AuthPayload)
+ payload.AccessKey = hex.EncodeToString(publicKey.SerializeCompressed())
+
+ // auth_nonce is a random unique string to seed the signing message
+ // this can be checked server side to make sure the request is not being replayed
+ payload.AuthNonce, err = cryptoutil.RandomHex(32)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate random hexadecimal string: %w", err)
+ }
+
+ return createSignatureCommon(payload, bodyString, privateKey)
+}
diff --git a/internal/auth/authenitcators.go b/internal/auth/authenitcators.go
new file mode 100644
index 00000000..0bf9de4d
--- /dev/null
+++ b/internal/auth/authenitcators.go
@@ -0,0 +1,133 @@
+package auth
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+)
+
+type XpubAuthenticator struct {
+ hdKey *bip32.ExtendedKey
+}
+
+func (x *XpubAuthenticator) Authenticate(r *resty.Request) error {
+ xPub, err := bip32.GetExtendedPublicKey(x.hdKey)
+ if err != nil {
+ return fmt.Errorf("failed to get extended public key: %w", err)
+ }
+
+ r.SetHeader(models.AuthHeader, xPub)
+ return nil
+}
+
+type XprivAuthenticator struct {
+ xpubAuth *XpubAuthenticator
+ xpriv *bip32.ExtendedKey
+}
+
+func (x *XprivAuthenticator) Authenticate(r *resty.Request) error {
+ err := x.xpubAuth.Authenticate(r)
+ if err != nil {
+ return fmt.Errorf("failed to set xpub header: %w", err)
+ }
+
+ body := bodyString(r)
+ header := make(http.Header)
+ err = setSignature(&header, x.xpriv, body)
+ if err != nil {
+ return fmt.Errorf("failed to sign request with xpriv: %w", err)
+ }
+
+ r.SetHeaderMultiValues(header)
+ return nil
+}
+
+type AccessKeyAuthenticator struct {
+ priv *ec.PrivateKey
+ pub *ec.PublicKey
+}
+
+func (a *AccessKeyAuthenticator) Authenticate(r *resty.Request) error {
+ r.Header.Set(models.AuthAccessKey, a.pubKeyHex())
+ body := bodyString(r)
+ sign, err := createSignatureAccessKey(a.privKeyHex(), body)
+ if err != nil {
+ return fmt.Errorf("failed to sign request with access key: %w", err)
+ }
+
+ setSignatureHeaders(&r.Header, sign)
+ return nil
+}
+
+func (a *AccessKeyAuthenticator) privKeyHex() string {
+ return hex.EncodeToString(a.priv.Serialize())
+}
+
+func (a *AccessKeyAuthenticator) pubKeyHex() string {
+ return hex.EncodeToString(a.pub.SerializeCompressed())
+}
+
+func bodyString(r *resty.Request) string {
+ switch r.Method {
+ case http.MethodGet:
+ return ""
+ }
+ return ""
+}
+
+func NewXprivAuthenticator(xpriv string) (*XprivAuthenticator, error) {
+ if xpriv == "" {
+ return nil, goclienterr.ErrEmptyXprivKey
+ }
+
+ hdKey, err := bip32.GenerateHDKeyFromString(xpriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse xpriv key: %w", err)
+ }
+
+ return &XprivAuthenticator{
+ xpriv: hdKey,
+ xpubAuth: &XpubAuthenticator{hdKey: hdKey},
+ }, nil
+}
+
+func NewAccessKeyAuthenticator(accessKeyHex string) (*AccessKeyAuthenticator, error) {
+ if accessKeyHex == "" {
+ return nil, goclienterr.ErrEmptyAccessKey
+ }
+
+ privKeyBytes, err := hex.DecodeString(accessKeyHex)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode private key string: %w", err)
+ }
+
+ privKey, pubKey := ec.PrivateKeyFromBytes(privKeyBytes)
+ if privKey == nil || pubKey == nil {
+ return nil, errors.New("failed to parse private key: key generation resulted in nil")
+ }
+
+ return &AccessKeyAuthenticator{
+ priv: privKey,
+ pub: pubKey,
+ }, nil
+}
+
+func NewXpubOnlyAuthenticator(xpub string) (*XpubAuthenticator, error) {
+ if xpub == "" {
+ return nil, goclienterr.ErrEmptyPubKey
+ }
+
+ xpubKey, err := bip32.GetHDKeyFromExtendedPublicKey(xpub)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse xpub key: %w", err)
+ }
+
+ return &XpubAuthenticator{hdKey: xpubKey}, nil
+}
diff --git a/internal/auth/authenticators_test.go b/internal/auth/authenticators_test.go
new file mode 100644
index 00000000..060be547
--- /dev/null
+++ b/internal/auth/authenticators_test.go
@@ -0,0 +1,121 @@
+package auth_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/go-resty/resty/v2"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ xAuthKey = "X-Auth-Key"
+ xAuthXPubKey = "X-Auth-Xpub"
+ xAuthHashKey = "X-Auth-Hash"
+ xAuthNonceKey = "X-Auth-Nonce"
+ xAuthTimeKey = "X-Auth-Time"
+ xAuthSignatureKey = "X-Auth-Signature"
+)
+
+func TestAccessKeyAuthenitcator_NewWithNilAccessKey(t *testing.T) {
+ // when:
+ authenticator, err := auth.NewAccessKeyAuthenticator("")
+
+ // then:
+ require.Nil(t, authenticator)
+ require.ErrorIs(t, err, errors.ErrEmptyAccessKey)
+}
+
+func TestAccessKeyAuthenticator_Authenticate(t *testing.T) {
+ // given:
+ authenticator, err := auth.NewAccessKeyAuthenticator(testutils.UserPrivAccessKey)
+ require.NotNil(t, authenticator)
+ require.NoError(t, err)
+
+ req := resty.New().R()
+
+ // when:
+ err = authenticator.Authenticate(req)
+
+ // then:
+ require.NoError(t, err)
+ requireXAuthHeaderToBeSet(t, req.Header)
+ requireSignatureHeadersToBeSet(t, req.Header)
+}
+
+func TestXprivAuthenitcator_NewWithNilXpriv(t *testing.T) {
+ // when:
+ authenticator, err := auth.NewXprivAuthenticator("")
+
+ // then:
+ require.Nil(t, authenticator)
+ require.ErrorIs(t, err, errors.ErrEmptyXprivKey)
+}
+
+func TestXprivAuthenitcator_Authenticate(t *testing.T) {
+ // given:
+ authenticator, err := auth.NewXprivAuthenticator(testutils.UserXPriv)
+ require.NotNil(t, authenticator)
+ require.NoError(t, err)
+
+ req := resty.New().R()
+
+ // when:
+ err = authenticator.Authenticate(req)
+
+ // then:
+ require.NoError(t, err)
+ requireXpubHeaderToBeSet(t, req.Header)
+ requireSignatureHeadersToBeSet(t, req.Header)
+}
+
+func TestXpubOnlyAuthenticator_NewWithNilXpub(t *testing.T) {
+ // when:
+ authenticator, err := auth.NewXpubOnlyAuthenticator("")
+
+ // then:
+ require.Nil(t, authenticator)
+ require.ErrorIs(t, err, errors.ErrEmptyPubKey)
+}
+
+func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) {
+ // given:
+ authenticator, err := auth.NewXpubOnlyAuthenticator(testutils.UserXPriv)
+ require.NotNil(t, authenticator)
+ require.NoError(t, err)
+
+ req := resty.New().R()
+
+ // when:
+ err = authenticator.Authenticate(req)
+
+ // then:
+ require.NoError(t, err)
+ requireXpubHeaderToBeSet(t, req.Header)
+}
+
+func requireXAuthHeaderToBeSet(t *testing.T, h http.Header) {
+ require.Equal(t, []string{testutils.UserPubAccessKey}, h[xAuthKey])
+}
+
+func requireXpubHeaderToBeSet(t *testing.T, h http.Header) {
+ require.Equal(t, []string{testutils.UserXPub}, h[xAuthXPubKey])
+}
+
+func requireSignatureHeadersToBeSet(t *testing.T, h http.Header) {
+ expected := []string{
+ xAuthHashKey,
+ xAuthNonceKey,
+ xAuthTimeKey,
+ xAuthSignatureKey,
+ }
+
+ actual := make([]string, 0, len(expected))
+ for k := range h {
+ actual = append(actual, k)
+ }
+ require.Subset(t, actual, expected)
+}
diff --git a/internal/constants/api.go b/internal/constants/api.go
new file mode 100644
index 00000000..5b47c419
--- /dev/null
+++ b/internal/constants/api.go
@@ -0,0 +1,27 @@
+package constants
+
+const (
+ AdminUtxosAPI = "admin/utxos"
+ AdminContactsAPI = "admin/contacts"
+ AdminXPubsAPI = "admin/xpubs"
+ AdminSharedConfigAPI = "admin/shared-config"
+ AdminInvitationsAPI = "admin/invitations"
+ AdminTransactionsAPI = "admin/transactions"
+ AdminAccessKeyAPI = "admin/access-key"
+ AdminWebhooksAPI = "admin/webhooks"
+ AdminPaymailAPI = "admin/paymail"
+ AdminStatusAPI = "admin/status"
+ AdminStatsAPI = "admin/stats"
+ UserTransactionsAPI = "user/transactions"
+ UserUtxosAPI = "user/utxos"
+ UserContactsAPI = "user/contacts"
+ UserXPubsAPI = "user/xpubs"
+ UserSharedConfigAPI = "user/shared-config"
+ UserInvitationsAPI = "user/invitations"
+ UserAccessKeyAPI = "user/access-key"
+ UserWebhooksAPI = "user/webhooks"
+ UserPaymailAPI = "user/paymail"
+ UserStatusAPI = "user/status"
+ UserStatsAPI = "user/stats"
+ UserMerkleRootAPI = "user/merkle-root"
+)
diff --git a/internal/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go
new file mode 100644
index 00000000..4b3a7771
--- /dev/null
+++ b/internal/cryptoutil/cryptoutil.go
@@ -0,0 +1,114 @@
+package cryptoutil
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
+ goclienterrors "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+)
+
+const (
+ XpubKeyLength = 111
+ ChainInternal = uint32(1)
+ ChainExternal = uint32(0)
+)
+
+func Hash(s string) string {
+ bb := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(bb[:])
+}
+
+func RandomHex(n int) (string, error) {
+ bb := make([]byte, n)
+ _, err := rand.Read(bb)
+ if err != nil {
+ return "", fmt.Errorf("failed to read bytes after rand: %w", err)
+ }
+
+ return hex.EncodeToString(bb), nil
+}
+
+func DeriveChildKeyFromHex(key *bip32.ExtendedKey, hexHash string) (*bip32.ExtendedKey, error) {
+ nums, err := ParseChildNumsFromHex(hexHash)
+ if err != nil {
+ return nil, fmt.Errorf("failed to return parsed child nums from hex hash: %w", err)
+ }
+
+ child := key
+ for _, n := range nums {
+ child, err = child.Child(n)
+ if err != nil {
+ return nil, fmt.Errorf("failed to return derived child extended key: %w", err)
+ }
+ }
+ return child, nil
+}
+
+func ParseChildNumsFromHex(hexHash string) ([]uint32, error) {
+ if hexHash == "" {
+ return nil, nil
+ }
+
+ const size = 8
+ parts := (len(hexHash) + size - 1) / size // Avoids the need for floating-point division and ensures correct rounding up.
+ var nums []uint32
+ for i := 0; i < parts; i++ {
+ start := i * size
+ end := start + size
+ if end > len(hexHash) {
+ end = len(hexHash) // Adjust end to fit remaining substring.
+ }
+ num, err := parseHexPart(hexHash[start:end])
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse hex part %q: %w", hexHash[start:end], err)
+ }
+
+ nums = append(nums, num)
+ }
+ return nums, nil
+}
+
+func parseHexPart(part string) (uint32, error) {
+ i, err := strconv.ParseInt(part, 16, 64)
+ if err != nil {
+ return 0, errors.Join(err, goclienterrors.ErrHexHashPartIntParse)
+ }
+
+ u, err := Int64ToUint32(i % math.MaxInt32)
+ if err != nil {
+ return 0, fmt.Errorf("failed to convert int64 to uint32: %w", err)
+ }
+
+ return u, nil
+}
+
+func Int64ToUint32(value int64) (uint32, error) {
+ if value < 0 {
+ return 0, goclienterrors.ErrNegativeValueNotAllowed
+ }
+ if value > math.MaxUint32 {
+ return 0, goclienterrors.ErrMaxUint32LimitExceeded
+ }
+ return uint32(value), nil
+}
+
+func PrivateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) {
+ pk, err1 := ec.PrivateKeyFromWif(s)
+ if err1 == nil {
+ return pk, nil
+ }
+
+ pk, err2 := ec.PrivateKeyFromHex(s)
+ if err2 != nil {
+ return nil, errors.Join(err1, err2)
+ }
+
+ return pk, nil
+}
diff --git a/internal/cryptoutil/cryptoutil_test.go b/internal/cryptoutil/cryptoutil_test.go
new file mode 100644
index 00000000..1b7db174
--- /dev/null
+++ b/internal/cryptoutil/cryptoutil_test.go
@@ -0,0 +1,196 @@
+package cryptoutil_test
+
+import (
+ "math"
+ "testing"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ compat "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHash(t *testing.T) {
+ tests := map[string]struct {
+ expectedHash string
+ expectedErr error
+ input string
+ }{
+ "input: empty": {
+ expectedHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ input: "",
+ },
+ "input: 1234567": {
+ input: "1234567",
+ expectedHash: "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414",
+ },
+ "input: xpub": {
+ input: "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J",
+ expectedHash: "1a0b10d4eda0636aae1709e7e7080485a4d99af3ca2962c6e677cf5b53d8ab8c",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Run(name, func(t *testing.T) {
+ got := cryptoutil.Hash(tc.input)
+ require.Equal(t, tc.expectedHash, got)
+ })
+ })
+ }
+}
+
+func TestDeriveChildKeyFromHex(t *testing.T) {
+ const (
+ input = "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414"
+ XPriv = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ"
+ XPub = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J"
+ expectedXPriv = "xprvA8mj2ZL1w6Nqpi6D2amJLo4Gxy24tW9uv82nQKmamT2rkg5DgjzJZRFnW33e7QJwn65uUWSuN6YQyWrujNjZdVShPRnpNUSRVTru4cxaqfd"
+ expectedXPub = "xpub6Mm5S4rumTw93CAg8cJJhw11WzrZHxsmHLxPCiBCKnZqdUQNEHJZ7DaGMKucRzXPHtoS2ZqsVSRjxVbibEvwmR2wXkZDd8RrTftmm42cRsf"
+ )
+
+ generateHDKey := func(s string) *bip32.ExtendedKey {
+ k, err := compat.GenerateHDKeyFromString(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return k
+ }
+
+ t.Run("child extended key from xpriv", func(t *testing.T) {
+ key := generateHDKey(XPriv)
+ got, err := cryptoutil.DeriveChildKeyFromHex(key, input)
+
+ require.NoError(t, err)
+ require.Equal(t, expectedXPriv, got.String())
+ })
+
+ t.Run("child extended key from xpub", func(t *testing.T) {
+ key := generateHDKey(XPub)
+ got, err := cryptoutil.DeriveChildKeyFromHex(key, input)
+
+ require.NoError(t, err)
+ require.Equal(t, expectedXPub, got.String())
+ })
+
+ t.Run("extended public key from extended private key", func(t *testing.T) {
+ key := generateHDKey(XPriv)
+ child, err := cryptoutil.DeriveChildKeyFromHex(key, input)
+ require.NoError(t, err)
+
+ got, err := child.Neuter()
+ require.NoError(t, err)
+ require.Equal(t, expectedXPub, got.String())
+ })
+}
+
+func TestRandomHex(t *testing.T) {
+ tests := map[string]struct {
+ input int
+ expectedLen int
+ }{
+ "input: zero": {
+ input: 0,
+ expectedLen: 0,
+ },
+ "input: 100_000": {
+ input: 100_000,
+ expectedLen: 200_000,
+ },
+ "input: 16": {
+ input: 16,
+ expectedLen: 32,
+ },
+ "input: 32": {
+ input: 32,
+ expectedLen: 64,
+ },
+
+ "input: 8": {
+ input: 8,
+ expectedLen: 16,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := cryptoutil.RandomHex(tc.input)
+ require.NoError(t, err)
+ require.Len(t, got, tc.expectedLen)
+ })
+ }
+}
+
+func TestInt64ToUint32(t *testing.T) {
+ tests := map[string]struct {
+ input int64
+ expectedErr error
+ expectedUint32 uint32
+ }{
+ "input: negative value": {
+ input: -1,
+ expectedErr: errors.ErrNegativeValueNotAllowed,
+ expectedUint32: 0,
+ },
+ "input: max value exceeded": {
+ input: math.MaxUint32 + 1,
+ expectedErr: errors.ErrMaxUint32LimitExceeded,
+ expectedUint32: 0,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := cryptoutil.Int64ToUint32(tc.input)
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedUint32, got)
+ })
+ }
+}
+
+func TestParseChildNumsFromHex(t *testing.T) {
+ tests := map[string]struct {
+ hex string
+ expectedErr error
+ expectedResult []uint32
+ }{
+ "input: empty hex": {
+ hex: "",
+ expectedErr: nil,
+ expectedResult: nil,
+ },
+ "input: invalid hex": {
+ hex: "test",
+ expectedErr: errors.ErrHexHashPartIntParse,
+ expectedResult: nil,
+ },
+ "input: short hex ababab": {
+ hex: "ababab",
+ expectedErr: nil,
+ expectedResult: []uint32{11250603},
+ },
+ "input: medium hex 8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414": {
+ hex: "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414",
+ expectedErr: nil,
+ expectedResult: []uint32{
+ 196136815, // 8bb0cf6e = 2343620462 - 2147483647
+ 967933200, // b9b17d0f = 3115416847 - 2147483647
+ 2099426390, // 7d22b456
+ 1897997694, // f121257d = 4045481341 - 2147483647
+ 1092963872, // c1254e1f = 3240447519 - 2147483647
+ 23483248, // 01665370
+ 1197704170, // 476383ea
+ 2003694612, // 776df414
+ },
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := cryptoutil.ParseChildNumsFromHex(tc.hex)
+ require.ErrorIs(t, err, tc.expectedErr)
+ require.Equal(t, tc.expectedResult, got)
+ })
+ }
+}
diff --git a/internal/restyutil/restyutil.go b/internal/restyutil/restyutil.go
new file mode 100644
index 00000000..c6d0550b
--- /dev/null
+++ b/internal/restyutil/restyutil.go
@@ -0,0 +1,36 @@
+package restyutil
+
+import (
+ "fmt"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+)
+
+type Authenticator interface {
+ Authenticate(r *resty.Request) error
+}
+
+func NewHTTPClient(cfg config.Config, auth Authenticator) *resty.Client {
+ return resty.New().
+ SetTransport(cfg.Transport).
+ SetBaseURL(cfg.Addr).
+ SetTimeout(cfg.Timeout).
+ OnBeforeRequest(func(_ *resty.Client, r *resty.Request) error {
+ return auth.Authenticate(r)
+ }).
+ SetError(&models.SPVError{}).
+ OnAfterResponse(func(_ *resty.Client, r *resty.Response) error {
+ if r.IsSuccess() {
+ return nil
+ }
+
+ if spvError, ok := r.Error().(*models.SPVError); ok && len(spvError.Code) > 0 {
+ return spvError
+ }
+
+ return fmt.Errorf("%w: %s", goclienterr.ErrUnrecognizedAPIResponse, r.Body())
+ })
+}
diff --git a/internal/restyutil/restyutil_test.go b/internal/restyutil/restyutil_test.go
new file mode 100644
index 00000000..9d04b3f6
--- /dev/null
+++ b/internal/restyutil/restyutil_test.go
@@ -0,0 +1,77 @@
+package restyutil_test
+
+import (
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/restyutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/testutils"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/go-resty/resty/v2"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/require"
+)
+
+// mockAuthenticator is a mock implementation of Authenticator interface
+type mockAuthenticator struct{}
+
+// Authenticate is a mock implementation of Authenticator interface
+func (m *mockAuthenticator) Authenticate(r *resty.Request) error {
+ return nil
+}
+
+// TestNewHTTPClient_OnAfterResponse tests the OnAfterResponse callback of NewHTTPClient
+func TestNewHTTPClient_OnAfterResponse(t *testing.T) {
+ tests := map[string]struct {
+ statusCode int
+ responseBody interface{}
+ expectedError error
+ expectedSPVError *models.SPVError
+ }{
+ "Success Response 200": {
+ statusCode: 200,
+ responseBody: map[string]string{"message": "success"},
+ expectedError: nil,
+ },
+ "Client Error 400": {
+ statusCode: 400,
+ responseBody: testutils.NewInvalidRequestError(),
+ expectedError: testutils.NewInvalidRequestError(),
+ },
+ "Server Error 500": {
+ statusCode: 500,
+ responseBody: testutils.NewUnrecognizedAPIResponseError(),
+ expectedError: testutils.NewUnrecognizedAPIResponseError(),
+ },
+ }
+
+ client := setupMockHTTPClient(t)
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ // Mock HTTP response
+ testutils.RegisterMockResponder(t, client, "/test", tc.statusCode, tc.responseBody)
+
+ // Make request
+ resp, err := client.R().Get("/test")
+
+ // Assert errors
+ require.ErrorIs(t, err, tc.expectedError)
+ require.NotNil(t, resp)
+
+ })
+ }
+}
+
+// setupMockHTTPClient initializes an HTTP client with a mock configuration and authenticator
+func setupMockHTTPClient(t *testing.T) *resty.Client {
+ cfg := config.Config{
+ Addr: "http://mock-api",
+ Timeout: 5,
+ Transport: httpmock.DefaultTransport,
+ }
+ client := restyutil.NewHTTPClient(cfg, &mockAuthenticator{})
+ httpmock.ActivateNonDefault(client.GetClient())
+ t.Cleanup(httpmock.DeactivateAndReset)
+ return client
+}
diff --git a/internal/testutils/errors_responses.go b/internal/testutils/errors_responses.go
new file mode 100644
index 00000000..9bbf80c1
--- /dev/null
+++ b/internal/testutils/errors_responses.go
@@ -0,0 +1,73 @@
+package testutils
+
+import (
+ "net/http"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/errors"
+ "github.com/bitcoin-sv/spv-wallet/models"
+)
+
+// NewBadRequestSPVError creates a new SPVError for bad request
+func NewBadRequestSPVError() models.SPVError {
+ return models.SPVError{
+ Message: http.StatusText(http.StatusBadRequest),
+ StatusCode: http.StatusBadRequest,
+ Code: models.UnknownErrorCode,
+ }
+}
+
+// NewConflictRequestSPVError returns an example SPV error returned by SPV Wallet API to indicate
+// a request conflict with the current state of the target resource.
+func NewConflictRequestSPVError() models.SPVError {
+ return models.SPVError{
+ Code: models.UnknownErrorCode,
+ Message: http.StatusText(http.StatusConflict),
+ StatusCode: http.StatusConflict,
+ }
+}
+
+// NewResourceNotFoundSPVError returns an example SPV error returned by SPV Wallet API to indicate
+// that the server cannot find the requested resource.
+func NewResourceNotFoundSPVError() models.SPVError {
+ return models.SPVError{
+ Code: models.UnknownErrorCode,
+ Message: http.StatusText(http.StatusNotFound),
+ StatusCode: http.StatusNotFound,
+ }
+}
+
+// NewUnauthorizedAccessSPVError creates a new SPVError for unauthorized access
+func NewUnauthorizedAccessSPVError() models.SPVError {
+ return models.SPVError{
+ Message: "unauthorized",
+ StatusCode: http.StatusUnauthorized,
+ Code: "error-unauthorized",
+ }
+}
+
+// NewInternalServerSPVError creates a new SPVError for internal server error
+func NewInternalServerSPVError() models.SPVError {
+ return models.SPVError{
+ Message: http.StatusText(http.StatusInternalServerError),
+ StatusCode: http.StatusInternalServerError,
+ Code: models.UnknownErrorCode,
+ }
+}
+
+// NewUnrecognizedAPIResponseError creates a new SPVError for unrecognized API response
+func NewUnrecognizedAPIResponseError() models.SPVError {
+ return models.SPVError{
+ Message: errors.ErrUnrecognizedAPIResponse.Error(),
+ StatusCode: http.StatusInternalServerError,
+ Code: "internal-server-error",
+ }
+}
+
+// NewInvalidRequestError creates a new SPVError for invalid request
+func NewInvalidRequestError() models.SPVError {
+ return models.SPVError{
+ Message: "Invalid request",
+ StatusCode: http.StatusBadRequest,
+ Code: "invalid-request",
+ }
+}
diff --git a/internal/testutils/mock_merkleroots.go b/internal/testutils/mock_merkleroots.go
new file mode 100644
index 00000000..ece41c4b
--- /dev/null
+++ b/internal/testutils/mock_merkleroots.go
@@ -0,0 +1,46 @@
+package testutils
+
+import (
+ "fmt"
+
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/stretchr/testify/mock"
+)
+
+// Mock repository for testing
+type MockMerkleRootsRepository struct {
+ mock.Mock
+}
+
+// GetLastMerkleRoot retrieves the last Merkle root from storage.
+func (m *MockMerkleRootsRepository) GetLastMerkleRoot() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+// SaveMerkleRoots appends synced Merkle roots to the simulated storage.
+func (m *MockMerkleRootsRepository) SaveMerkleRoots(roots []models.MerkleRoot) error {
+ args := m.Called(roots)
+ return fmt.Errorf("SaveMerkleRoots error: %w", args.Error(0))
+}
+
+// SetupMerkleRootMockRepo sets up a mock MerkleRootsRepository with common behavior
+func SetupMerkleRootMockRepo(mockRepo *MockMerkleRootsRepository, expectedCallCount int) {
+ mockRepo.On("GetLastMerkleRoot").Return("") // Start with no data
+
+ // Match any non-empty slice of MerkleRoot models
+ mockRepo.On("SaveMerkleRoots", mock.MatchedBy(func(roots []models.MerkleRoot) bool {
+ return len(roots) > 0
+ })).Return(nil).Times(expectedCallCount)
+}
+
+// SetupStaleKeyMock sets up the mock repository for a stale LastEvaluatedKey scenario
+func SetupStaleKeyMock(mockRepo *MockMerkleRootsRepository) {
+ mockRepo.On("GetLastMerkleRoot").Return("stale-key") // Simulate a stale key
+ mockRepo.On("SaveMerkleRoots", mock.Anything).Return(nil)
+}
+
+// SetupEmptyMerkleRootMock sets up a mock MerkleRootsRepository with no data
+func SetupEmptyMerkleRootMock(mockRepo *MockMerkleRootsRepository) {
+ mockRepo.On("GetLastMerkleRoot").Return("") // Simulate no data
+}
diff --git a/internal/testutils/mock_responders.go b/internal/testutils/mock_responders.go
new file mode 100644
index 00000000..e5a1a72f
--- /dev/null
+++ b/internal/testutils/mock_responders.go
@@ -0,0 +1,70 @@
+package testutils
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/go-resty/resty/v2"
+ "github.com/jarcoal/httpmock"
+)
+
+// NewBadRequestSPVError returns a new SPVError object with status code 400
+func NewJSONFileResponderWithStatusOK(filePath string) httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File(filePath))
+}
+
+// NewBadRequestSPVError returns a new SPVError object with status code 400
+func NewJSONBodyResponderWithStatusOK(body any) httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusOK, body)
+}
+
+// NewStringResponderStatusOK returns a new responder with status code 200 and a body
+func NewStringResponderStatusOK(body string) httpmock.Responder {
+ return httpmock.NewStringResponder(http.StatusOK, body)
+}
+
+// NewBadRequestSPVErrorResponder returns a new SPVError object with status code 400
+func NewBadRequestSPVErrorResponder() httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError())
+}
+
+// NewResourceNotFoundSPVErrorResponder returns a new SPVError object with status code 404.
+func NewResourceNotFoundSPVErrorResponder() httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusNotFound, NewResourceNotFoundSPVError())
+}
+
+// NewConflictRequestSPVErrorResponder returns a new SPVError object with status code 409.
+func NewConflictRequestSPVErrorResponder() httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusConflict, NewConflictRequestSPVError())
+}
+
+// NewInternalServerSPVErrorResponder returns a new SPVError object with status code 500
+func NewInternalServerSPVErrorResponder() httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, NewInternalServerSPVError())
+}
+
+// NewInternalServerSPVErrorStringResponder returns a new SPVError object with status code 500
+func NewInternalServerSPVErrorStringResponder(errMessage string) httpmock.Responder {
+ return httpmock.NewStringResponder(http.StatusInternalServerError, errMessage)
+}
+
+// NewUnauthorizedAccessSPVErrorResponder returns a new SPVError object with status code 401
+func NewUnauthorizedAccessSPVErrorResponder() httpmock.Responder {
+ return httpmock.NewJsonResponderOrPanic(http.StatusUnauthorized, NewUnauthorizedAccessSPVError())
+}
+
+// RegisterMockResponder registers a mock responder for a given endpoint
+func RegisterMockResponder(t *testing.T, client *resty.Client, endpoint string, statusCode int, responseBody interface{}) {
+ url := client.BaseURL + endpoint
+ httpmock.RegisterResponder("GET", url, httpmock.NewJsonResponderOrPanic(statusCode, responseBody))
+ t.Cleanup(func() { httpmock.Reset() })
+}
+
+// NewPaginatedJSONResponder creates a responder that simulates paginated responses
+func NewPaginatedJSONResponder(t *testing.T, files ...string) httpmock.Responder {
+ responses := make([]*http.Response, 0, len(files))
+ for _, file := range files {
+ responses = append(responses, httpmock.NewStringResponse(http.StatusOK, httpmock.File(file).String())) //nolint: bodyclose
+ }
+ return httpmock.ResponderFromMultipleResponses(responses)
+}
diff --git a/internal/testutils/spvwallettest.go b/internal/testutils/spvwallettest.go
new file mode 100644
index 00000000..28070507
--- /dev/null
+++ b/internal/testutils/spvwallettest.go
@@ -0,0 +1,110 @@
+package testutils
+
+import (
+ "encoding/hex"
+ "net/url"
+ "path"
+ "testing"
+ "time"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ spvwallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/jarcoal/httpmock"
+)
+
+const TestAPIAddr = "http://localhost:3003"
+
+const (
+ UserXPriv = "xprv9s21ZrQH143K3fqNnUmXmgfT9ToMtiq5cuKsVBG4E5UqVh4psHDY2XKsEfZKuV4FSZcPS9CYgEQiLUpW2xmHqHFyp23SvTkTCE153cCdwaj"
+ UserXPub = "xpub661MyMwAqRbcG9uqtWJY8pcBhVdrJBYvz8FUHZffnR1pNVPyQpXnaKeM5w2FyH5Wwhf5Cf15mFDVRZnuK9sEHDqqd39qWz36UDoobrzLyFM"
+ UserPrivAccessKey = "03a446ede05f04fd92d2707599a80b67ad76f63b3958706819c76308bfc7c1143d"
+ UserPubAccessKey = "0239a60e37d62b0217ac86881caba194ab943e18099c080de70c173daf75d917b2"
+ PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"
+
+ AliceXPriv = "xprv9s21ZrQH143K4JFXqGhBzdrthyNFNuHPaMUwvuo8xvpHwWXprNK7T4JPj1w53S1gojQncyj8JhSh8qouYPZpbocsq934cH5G1t1DRBfgbod"
+ AliceXPub = "xpub661MyMwAqRbcGnKzwJECMmodG1CjnN1EwaQYjJCkXGMGpJryPudMzrcsaK6frwUxXqFxRJwPkKvJh6myJEpQPJS9N67jhZWr24biGe277DH"
+ BobXPriv = "xprv9s21ZrQH143K4VneY3UWCF1o5Kk2tmgGrGtMtsrThCTsHsszEZ6H1iP37ZTwuUBvMwudG68SRkcfTjeu8h3rkayfyqkjKAStFBkuNsBnAkS"
+ BobXPub = "xpub661MyMwAqRbcGys7e51WZNxXdMaXJEQ8DVoxhGG5FXzrAgD8n6QXZWhWxrm2yMzH8e9fxV8TYxmkL9sivVEEoPfDpg4u5mrp2VTqvfGT1Us"
+)
+
+func GivenSPVUserAPI(t *testing.T) (*spvwallet.UserAPI, *httpmock.MockTransport) {
+ t.Helper()
+ transport := httpmock.NewMockTransport()
+ cfg := config.Config{
+ Addr: TestAPIAddr,
+ Timeout: 5 * time.Second,
+ Transport: transport,
+ }
+
+ spv, err := spvwallet.NewUserAPIWithXPriv(cfg, UserXPriv)
+ if err != nil {
+ t.Fatalf("test helper - spv wallet client with xpriv: %s", err)
+ }
+
+ return spv, transport
+}
+
+func GivenSPVAdminAPI(t *testing.T) (*spvwallet.AdminAPI, *httpmock.MockTransport) {
+ t.Helper()
+ transport := httpmock.NewMockTransport()
+ cfg := config.Config{
+ Addr: TestAPIAddr,
+ Timeout: 5 * time.Second,
+ Transport: transport,
+ }
+
+ api, err := spvwallet.NewAdminAPIWithXPriv(cfg, UserXPriv)
+ if err != nil {
+ t.Fatalf("test helper - admin api with xPub: %s", err)
+ }
+
+ return api, transport
+}
+
+func MockPKI(t *testing.T, xpub string) string {
+ t.Helper()
+ xPub, _ := bip32.NewKeyFromString(xpub)
+ var err error
+ for i := 0; i < 3; i++ { //magicNumberOfInheritance is 3 -> 2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI
+ xPub, err = xPub.Child(0)
+ if err != nil {
+ t.Fatalf("test helper - retrieve a derived child extended key at index 0 failed: %s", err)
+ }
+ }
+
+ pubKey, err := xPub.ECPubKey()
+ if err != nil {
+ t.Fatalf("test helper - ec public key from xpub: %s", err)
+ }
+
+ return hex.EncodeToString(pubKey.SerializeCompressed())
+}
+
+func ParseTime(t *testing.T, s string) time.Time {
+ ts, err := time.Parse(time.RFC3339Nano, s)
+ if err != nil {
+ t.Fatalf("test helper - time parse: %s", err)
+ }
+ return ts
+}
+
+func Ptr[T any](value T) *T {
+ return &value
+}
+
+// FullAPIURL constructs a full URL by combining the base address and an endpoint path.
+// It uses the testing context to fail gracefully if invalid input is provided.
+func FullAPIURL(t *testing.T, endpoint string, pathParams ...string) string {
+ t.Helper()
+ baseURL, err := url.Parse(TestAPIAddr)
+ if err != nil {
+ t.Fatalf("invalid TestAPIAddr: %s, error: %v", TestAPIAddr, err)
+ }
+
+ // Join the base path with additional path components
+ fullPath := path.Join(append([]string{baseURL.Path, endpoint}, pathParams...)...)
+ baseURL.Path = fullPath
+
+ return baseURL.String()
+}
diff --git a/notifications/eventsMap.go b/notifications/events_map.go
similarity index 100%
rename from notifications/eventsMap.go
rename to notifications/events_map.go
diff --git a/notifications/interface.go b/notifications/interface.go
deleted file mode 100644
index 610ea2b7..00000000
--- a/notifications/interface.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package notifications
-
-import "context"
-
-// WebhookSubscriber - interface for subscribing and unsubscribing to webhooks
-type WebhookSubscriber interface {
- AdminSubscribeWebhook(ctx context.Context, webhookURL, tokenHeader, tokenValue string) error
- AdminUnsubscribeWebhook(ctx context.Context, webhookURL string) error
-}
diff --git a/notifications/options.go b/notifications/options.go
deleted file mode 100644
index 436a3ed7..00000000
--- a/notifications/options.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package notifications
-
-import (
- "context"
- "runtime"
-)
-
-// WebhookOptions - options for the webhook
-type WebhookOptions struct {
- TokenHeader string
- TokenValue string
- BufferSize int
- RootContext context.Context
- Processors int
-}
-
-// NewWebhookOptions - creates a new webhook options
-func NewWebhookOptions() *WebhookOptions {
- return &WebhookOptions{
- TokenHeader: "",
- TokenValue: "",
- BufferSize: 100,
- Processors: runtime.NumCPU(),
- RootContext: context.Background(),
- }
-}
-
-// WebhookOpts - functional options for the webhook
-type WebhookOpts = func(*WebhookOptions)
-
-// WithToken - sets the token header and value
-func WithToken(tokenHeader, tokenValue string) WebhookOpts {
- return func(w *WebhookOptions) {
- w.TokenHeader = tokenHeader
- w.TokenValue = tokenValue
- }
-}
-
-// WithBufferSize - sets the buffer size
-func WithBufferSize(size int) WebhookOpts {
- return func(w *WebhookOptions) {
- w.BufferSize = size
- }
-}
-
-// WithRootContext - sets the root context
-func WithRootContext(ctx context.Context) WebhookOpts {
- return func(w *WebhookOptions) {
- w.RootContext = ctx
- }
-}
-
-// WithProcessors - sets the number of concurrent loops which will process the events
-func WithProcessors(count int) WebhookOpts {
- return func(w *WebhookOptions) {
- w.Processors = count
- }
-}
diff --git a/notifications/registerer.go b/notifications/registrerer.go
similarity index 100%
rename from notifications/registerer.go
rename to notifications/registrerer.go
diff --git a/notifications/webhook.go b/notifications/webhook.go
index 0dd488da..995dca86 100644
--- a/notifications/webhook.go
+++ b/notifications/webhook.go
@@ -3,13 +3,73 @@ package notifications
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"reflect"
+ "runtime"
"time"
"github.com/bitcoin-sv/spv-wallet/models"
)
+// WebhookOptions - options for the webhook
+type WebhookOptions struct {
+ TokenHeader string
+ TokenValue string
+ BufferSize int
+ RootContext context.Context
+ Processors int
+}
+
+// NewWebhookOptions - creates a new webhook options
+func NewWebhookOptions() *WebhookOptions {
+ return &WebhookOptions{
+ TokenHeader: "",
+ TokenValue: "",
+ BufferSize: 100,
+ Processors: runtime.NumCPU(),
+ RootContext: context.Background(),
+ }
+}
+
+// WebhookOpts - functional options for the webhook
+type WebhookOpts = func(*WebhookOptions)
+
+// WithToken - sets the token header and value
+func WithToken(tokenHeader, tokenValue string) WebhookOpts {
+ return func(w *WebhookOptions) {
+ w.TokenHeader = tokenHeader
+ w.TokenValue = tokenValue
+ }
+}
+
+// WithBufferSize - sets the buffer size
+func WithBufferSize(size int) WebhookOpts {
+ return func(w *WebhookOptions) {
+ w.BufferSize = size
+ }
+}
+
+// WithRootContext - sets the root context
+func WithRootContext(ctx context.Context) WebhookOpts {
+ return func(w *WebhookOptions) {
+ w.RootContext = ctx
+ }
+}
+
+// WithProcessors - sets the number of concurrent loops which will process the events
+func WithProcessors(count int) WebhookOpts {
+ return func(w *WebhookOptions) {
+ w.Processors = count
+ }
+}
+
+// WebhookSubscriber - interface for subscribing and unsubscribing to webhooks
+type WebhookSubscriber interface {
+ AdminSubscribeWebhook(ctx context.Context, webhookURL, tokenHeader, tokenValue string) error
+ AdminUnsubscribeWebhook(ctx context.Context, webhookURL string) error
+}
+
// Webhook - the webhook event receiver
type Webhook struct {
URL string
@@ -41,12 +101,20 @@ func NewWebhook(subscriber WebhookSubscriber, url string, opts ...WebhookOpts) *
// Subscribe - sends a subscription request to the spv-wallet
func (w *Webhook) Subscribe(ctx context.Context) error {
- return w.subscriber.AdminSubscribeWebhook(ctx, w.URL, w.options.TokenHeader, w.options.TokenValue)
+ err := w.subscriber.AdminSubscribeWebhook(ctx, w.URL, w.options.TokenHeader, w.options.TokenValue)
+ if err != nil {
+ return fmt.Errorf("failed to subscribe webhook: %w", err)
+ }
+ return nil
}
// Unsubscribe - sends an unsubscription request to the spv-wallet
func (w *Webhook) Unsubscribe(ctx context.Context) error {
- return w.subscriber.AdminUnsubscribeWebhook(ctx, w.URL)
+ err := w.subscriber.AdminUnsubscribeWebhook(ctx, w.URL)
+ if err != nil {
+ return fmt.Errorf("failed to unsubscribe webhook: %w", err)
+ }
+ return nil
}
// HTTPHandler - returns an http handler for the webhook; it should be registered with the http server
diff --git a/queries/queries.go b/queries/queries.go
new file mode 100644
index 00000000..df9cbd9e
--- /dev/null
+++ b/queries/queries.go
@@ -0,0 +1,127 @@
+package queries
+
+import (
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+// ContactsPage is an alias for the user contacts response page model returned by the SPV Wallet API.
+// It provides a paginated list of user contacts along with pagination metadata.
+type ContactsPage = response.PageModel[response.Contact]
+
+// AccessKeyPage is an alias for the access key response page model
+// returned by the SPV Wallet API, which contains a paginated list of
+// access keys along with pagination metadata.
+type AccessKeyPage = response.PageModel[response.AccessKey]
+
+// PaymailsPage is an alias for the paymail addresses response page model returned by the SPV Wallet API.
+// It provides a paginated list of paymails along with pagination metadata.
+type PaymailsPage = response.PageModel[response.PaymailAddress]
+
+// TransactionPage is an alias for the transactions response page model
+// returned by the SPV Wallet API, which contains a paginated list of
+// transactions along with pagination metadata.
+type TransactionPage = response.PageModel[response.Transaction]
+
+// UtxosPage is an alias for the UTXOs response page model returned by the SPV Wallet API.
+// It contains a paginated list of UTXOs along with pagination metadata.
+type UtxosPage = response.PageModel[response.Utxo]
+
+// XPubPage represents a paginated response model containing XPubs,
+// as provided by the SPV Wallet API.
+type XPubPage = response.PageModel[response.Xpub]
+
+// QueryOption defines a functional option for configuring a generic query instance.
+// These options allow flexible setup of filters, metadata, and pagination for the query.
+type QueryOption[F QueryFilters] func(*Query[F])
+
+// QueryWithMetadataFilter adds metadata filters to the search parameters URL.
+// The provided metadata attributes are appended as query parameters.
+func QueryWithMetadataFilter[F QueryFilters](m map[string]any) QueryOption[F] {
+ return func(q *Query[F]) {
+ q.Metadata = m
+ }
+}
+
+// QueryWithPageFilter adds pagination filters, such as page number, size, and sorting options,
+// to the search URL as query parameters.
+func QueryWithPageFilter[F QueryFilters](f filter.Page) QueryOption[F] {
+ return func(q *Query[F]) {
+ q.PageFilter = f
+ }
+}
+
+// QueryWithFilter adds search parameters to the search URL corresponding to the specified filter type.
+func QueryWithFilter[F QueryFilters](f F) QueryOption[F] {
+ return func(q *Query[F]) {
+ q.Filter = f
+ }
+}
+
+// AdminQueryFilters aggregates the supported query filter types used for constructing query parameters
+// for SPV Wallet API admin search endpoints.
+type AdminQueryFilters interface {
+ filter.AdminAccessKeyFilter | filter.AdminUtxoFilter | filter.AdminPaymailFilter | filter.XpubFilter | filter.AdminTransactionFilter | filter.AdminContactFilter
+}
+
+// NonAdminQueryFilters aggregates the supported query filter types used for constructing query parameters
+// for SPV Wallet API non-admin search endpoints.
+type NonAdminQueryFilters interface {
+ filter.AccessKeyFilter | filter.ContactFilter | filter.PaymailFilter | filter.TransactionFilter | filter.UtxoFilter
+}
+
+// QueryFilters aggregates filter types for both admin and non-admin query types.
+type QueryFilters interface {
+ AdminQueryFilters | NonAdminQueryFilters
+}
+
+// Query represents a generic query structure that aggregates metadata, pagination, and specific filters.
+type Query[F QueryFilters] struct {
+ Metadata map[string]any // Metadata filters for refining the search.
+ PageFilter filter.Page // Pagination details, including page number, size, and sorting.
+ Filter F // Specific filter for refining the query.
+}
+
+// NewQuery creates a new Query instance, applying the provided functional options.
+// It allows flexible configuration of metadata, filters, and pagination for the query.
+func NewQuery[F QueryFilters](opts ...QueryOption[F]) *Query[F] {
+ var q Query[F]
+ for _, o := range opts {
+ o(&q)
+ }
+ return &q
+}
+
+// MerkleRootPage is an alias for the Merkle roots response page model
+// returned by the SPV Wallet API. It provides a paginated list of Merkle roots
+// along with pagination metadata.
+type MerkleRootPage = models.MerkleRootsBHSResponse
+
+// MerkleRootsQuery aggregates query parameters for constructing a URL to retrieve Merkle roots.
+// These parameters, such as BatchSize and LastEvaluatedKey, control how the API request is processed.
+type MerkleRootsQuery struct {
+ BatchSize int // The number of Merkle roots to fetch in a single API request.
+ LastEvaluatedKey string // A key used for pagination, indicating where to continue the query.
+}
+
+// MerkleRootsQueryOption defines a functional option for customizing a MerkleRootsQuery.
+// These options allow for flexible configuration by applying filters like batch size or
+// the last evaluated key for pagination.
+type MerkleRootsQueryOption func(*MerkleRootsQuery)
+
+// MerkleRootsQueryWithBatchSize returns a MerkleRootsQueryOption to set the batch size for the query.
+// This option specifies how many Merkle roots should be retrieved in a single API request.
+func MerkleRootsQueryWithBatchSize(n int) MerkleRootsQueryOption {
+ return func(q *MerkleRootsQuery) {
+ q.BatchSize = n
+ }
+}
+
+// MerkleRootsQueryWithLastEvaluatedKey returns a MerkleRootsQueryOption to set the last evaluated key for pagination.
+// This option uses the last processed Merkle root in the client's database to continue the query.
+func MerkleRootsQueryWithLastEvaluatedKey(key string) MerkleRootsQueryOption {
+ return func(q *MerkleRootsQuery) {
+ q.LastEvaluatedKey = key
+ }
+}
diff --git a/regression_tests/Taskfile.yml b/regression_tests/Taskfile.yml
index 8c3e5be6..87ee1bf0 100644
--- a/regression_tests/Taskfile.yml
+++ b/regression_tests/Taskfile.yml
@@ -9,7 +9,7 @@ tasks:
desc: "running go regression tests"
cmds:
- echo "running go regression tests..."
- - go test -tags=regression ./...
+ - go test -tags=regression ./... -v
dir: .
env:
CLIENT_ONE_URL: "{{.CLIENT_ONE_URL}}"
diff --git a/regression_tests/regression_test.go b/regression_tests/regression_test.go
deleted file mode 100644
index ce2a573e..00000000
--- a/regression_tests/regression_test.go
+++ /dev/null
@@ -1,133 +0,0 @@
-//go:build regression
-// +build regression
-
-package regressiontests
-
-import (
- "context"
- "fmt"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-const (
- minimalFundsPerTransaction = 2
-
- adminXPriv = "xprv9s21ZrQH143K3CbJXirfrtpLvhT3Vgusdo8coBritQ3rcS7Jy7sxWhatuxG5h2y1Cqj8FKmPp69536gmjYRpfga2MJdsGyBsnB12E19CESK"
- adminXPub = "xpub661MyMwAqRbcFgfmdkPgE2m5UjHXu9dj124DbaGLSjaqVESTWfCD4VuNmEbVPkbYLCkykwVZvmA8Pbf8884TQr1FgdG2nPoHR8aB36YdDQh"
-
- errGettingEnvVariables = "failed to get environment variables: %s"
- errGettingSharedConfig = "failed to get shared config: %s"
- errCreatingUser = "failed to create user: %s"
- errDeletingUserPaymail = "failed to delete user's paymail: %s"
- errSendingFunds = "failed to send funds: %s"
- errGettingBalance = "failed to get balance: %s"
- errGettingTransactions = "failed to get transactions: %s"
-)
-
-func TestRegression(t *testing.T) {
- ctx := context.Background()
- rtConfig, err := getEnvVariables()
- require.NoError(t, err, fmt.Sprintf(errGettingEnvVariables, err))
-
- var paymailDomainInstanceOne, paymailDomainInstanceTwo string
- var userOne, userTwo *regressionTestUser
-
- t.Run("Initialize Shared Configurations", func(t *testing.T) {
- t.Run("Should get sharedConfig for instance one", func(t *testing.T) {
- paymailDomainInstanceOne, err = getPaymailDomain(ctx, adminXPriv, rtConfig.ClientOneURL)
- require.NoError(t, err, fmt.Sprintf(errGettingSharedConfig, err))
- })
-
- t.Run("Should get shared config for instance two", func(t *testing.T) {
- paymailDomainInstanceTwo, err = getPaymailDomain(ctx, adminXPriv, rtConfig.ClientTwoURL)
- require.NoError(t, err, fmt.Sprintf(errGettingSharedConfig, err))
- })
- })
-
- t.Run("Create Users", func(t *testing.T) {
- t.Run("Should create user for instance one", func(t *testing.T) {
- userName := "instanceOneUser1"
- userOne, err = createUser(ctx, userName, paymailDomainInstanceOne, rtConfig.ClientOneURL, adminXPriv)
- require.NoError(t, err, fmt.Sprintf(errCreatingUser, err))
- })
-
- t.Run("Should create user for instance two", func(t *testing.T) {
- userName := "instanceTwoUser1"
- userTwo, err = createUser(ctx, userName, paymailDomainInstanceTwo, rtConfig.ClientTwoURL, adminXPriv)
- require.NoError(t, err, fmt.Sprintf(errCreatingUser, err))
- })
- })
-
- defer func() {
- t.Run("Cleanup: Remove Paymails", func(t *testing.T) {
- t.Run("Should remove user's paymail on first instance", func(t *testing.T) {
- if userOne != nil {
- err := removeRegisteredPaymail(ctx, userOne.Paymail, rtConfig.ClientOneURL, adminXPriv)
- require.NoError(t, err, fmt.Sprintf(errDeletingUserPaymail, err))
- }
- })
-
- t.Run("Should remove user's paymail on second instance", func(t *testing.T) {
- if userTwo != nil {
- err := removeRegisteredPaymail(ctx, userTwo.Paymail, rtConfig.ClientTwoURL, adminXPriv)
- require.NoError(t, err, fmt.Sprintf(errDeletingUserPaymail, err))
- }
- })
- })
- }()
-
- t.Run("Perform Transactions", func(t *testing.T) {
- t.Run("Send money to instance 1", func(t *testing.T) {
- const amountToSend = 3
- transaction, err := sendFunds(ctx, rtConfig.ClientTwoURL, rtConfig.ClientTwoLeaderXPriv, userOne.Paymail, amountToSend)
- require.NoError(t, err, fmt.Sprintf(errSendingFunds, err))
- require.GreaterOrEqual(t, int64(-1), transaction.OutputValue)
-
- balance, err := getBalance(ctx, rtConfig.ClientOneURL, userOne.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingBalance, err))
- require.GreaterOrEqual(t, balance, 1)
-
- transactions, err := getTransactions(ctx, rtConfig.ClientOneURL, userOne.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err))
- require.GreaterOrEqual(t, len(transactions), 1)
- })
-
- t.Run("Send money to instance 2", func(t *testing.T) {
- transaction, err := sendFunds(ctx, rtConfig.ClientOneURL, rtConfig.ClientOneLeaderXPriv, userTwo.Paymail, minimalFundsPerTransaction)
- require.NoError(t, err, fmt.Sprintf(errSendingFunds, err))
- require.GreaterOrEqual(t, int64(-1), transaction.OutputValue)
-
- balance, err := getBalance(ctx, rtConfig.ClientTwoURL, userTwo.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingBalance, err))
- require.GreaterOrEqual(t, balance, 1)
-
- transactions, err := getTransactions(ctx, rtConfig.ClientTwoURL, userTwo.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err))
- require.GreaterOrEqual(t, len(transactions), 1)
- })
-
- t.Run("Send money from instance 1 to instance 2", func(t *testing.T) {
- transaction, err := sendFunds(ctx, rtConfig.ClientOneURL, userOne.XPriv, userTwo.Paymail, minimalFundsPerTransaction)
- require.NoError(t, err, fmt.Sprintf(errSendingFunds, err))
- require.GreaterOrEqual(t, int64(-1), transaction.OutputValue)
-
- balance, err := getBalance(ctx, rtConfig.ClientTwoURL, userTwo.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingBalance, err))
- require.GreaterOrEqual(t, balance, 2)
-
- transactions, err := getTransactions(ctx, rtConfig.ClientTwoURL, userTwo.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err))
- require.GreaterOrEqual(t, len(transactions), 2)
-
- balance, err = getBalance(ctx, rtConfig.ClientOneURL, userOne.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingBalance, err))
- require.GreaterOrEqual(t, balance, 0)
-
- transactions, err = getTransactions(ctx, rtConfig.ClientOneURL, userOne.XPriv)
- require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err))
- require.GreaterOrEqual(t, len(transactions), 2)
- })
- })
-}
diff --git a/regression_tests/regression_workflow_test.go b/regression_tests/regression_workflow_test.go
new file mode 100644
index 00000000..3be94eb1
--- /dev/null
+++ b/regression_tests/regression_workflow_test.go
@@ -0,0 +1,301 @@
+//go:build regression
+// +build regression
+
+package regressiontests
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegressionWorkflow(t *testing.T) {
+ assert := assert.New(t)
+ ctx := context.Background()
+ spvWalletPG, spvWalletSL := initServers(t)
+
+ t.Log("Step 1: Setup success: created SPV client instances with test users")
+ t.Logf("SPV clients for env: %s, user: %s, admin: %s, leader: %s", spvWalletPG.cfg.envURL, spvWalletPG.user.alias, spvWalletPG.admin.alias, spvWalletPG.leader.alias)
+ t.Logf("SPV clients for env: %s, user: %s, admin: %s, leader: %s", spvWalletSL.cfg.envURL, spvWalletSL.user.alias, spvWalletSL.admin.alias, spvWalletSL.leader.alias)
+
+ t.Run("Step 2: The leader clients attempt to fetch the shared configuration response from their SPV Wallet API instance.", func(t *testing.T) {
+ tests := []struct {
+ name string
+ server *spvWalletServer
+ expectedPaymailsLen int
+ }{
+ {
+ name: fmt.Sprintf("%s should set paymail domain after fetching shared config", spvWalletPG.leader.alias),
+ server: spvWalletPG,
+ expectedPaymailsLen: 1,
+ },
+ {
+ name: fmt.Sprintf("%s should set paymail domain after fetching shared config", spvWalletSL.leader.alias),
+ server: spvWalletSL,
+ expectedPaymailsLen: 1,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ leader := tc.server.leader
+
+ // when:
+ got, err := leader.client.SharedConfig(ctx)
+
+ // then:
+ assert.NoError(err, "Shared config wasn't successful retrieved by %s. Expect to get nil error", leader.paymail)
+
+ if assert.NotNil(got.PaymailDomains, "Shared config should contain non-nil paymail domains slice") {
+ actualLen := len(got.PaymailDomains)
+
+ assert.Equal(tc.expectedPaymailsLen, actualLen, "Retrieved shared config should have %s paymail domains. Got: %d paymail domains", tc.expectedPaymailsLen, actualLen)
+ assert.NotEmpty(got.PaymailDomains[0], "Retrieved shared config should not be an empty string")
+
+ tc.server.setPaymailDomains(got.PaymailDomains[0])
+
+ logSuccessOp(t, err, "%s retrieved the shared configuration successfully. Leader set the paymail domains to admin, leader, user clients.", leader.paymail)
+ }
+ })
+ }
+ })
+
+ t.Run("Step 3: The SPV Wallet admin clients attempt to add a user's xPub records within the same environment by making a request to their SPV Wallet API instance.", func(t *testing.T) {
+ tests := []struct {
+ name string
+ server *spvWalletServer
+ }{
+ {
+ name: fmt.Sprintf("%s should add xPub record %s for %s", spvWalletPG.admin.paymail, spvWalletPG.user.xPub, spvWalletPG.user.paymail),
+ server: spvWalletPG,
+ },
+ {
+ name: fmt.Sprintf("%s should add xPub record %s for %s", spvWalletPG.admin.paymail, spvWalletSL.user.xPub, spvWalletSL.user.paymail),
+ server: spvWalletSL,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ admin := tc.server.admin
+ user := tc.server.user
+
+ // when:
+ xPub, err := admin.client.CreateXPub(ctx, &commands.CreateUserXpub{XPub: user.xPub})
+
+ // then:
+ assert.NoError(err, "xPub record %s wasn't created successfully for %s by %s. Expect to get nil error", user.xPub, user.paymail, admin.paymail)
+ assert.NotNil(xPub, "Expected to get non-nil xPub response after sending creation request by %s.", admin.paymail)
+
+ logSuccessOp(t, err, "xPub record %s was created successfully for %s by %s", user.xPub, user.paymail, admin.paymail)
+ })
+ }
+ })
+
+ t.Run("Step 4: The SPV Wallet admin clients attempt to add a user's paymail record within the same environment by making a request to their SPV Wallet API instance.", func(t *testing.T) {
+ tests := []struct {
+ name string
+ server *spvWalletServer
+ }{
+ {
+ name: fmt.Sprintf("%s should add paymail record %s for the user %s", spvWalletPG.admin.paymail, spvWalletPG.user.paymail, spvWalletPG.user.alias),
+ server: spvWalletPG,
+ },
+ {
+ name: fmt.Sprintf("%s should add paymail record %s for the user %s", spvWalletPG.admin.paymail, spvWalletSL.user.paymail, spvWalletSL.user.alias),
+ server: spvWalletSL,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ admin := tc.server.admin
+ user := tc.server.user
+
+ // when:
+ paymail, err := admin.client.CreatePaymail(ctx, &commands.CreatePaymail{
+ Key: user.xPub,
+ Address: user.paymail,
+ PublicName: "Regression tests",
+ })
+
+ // then:
+ assert.NoError(err, "Paymail record %s wasn't created successfully for %s by %s. Expect to get nil error", user.paymail, user.alias, admin.paymail)
+ assert.NotNil(paymail, "Expected to get non-nil paymail addresss response after sending creation request by %s.", admin.paymail)
+
+ logSuccessOp(t, err, "Paymail record %s was created successfully for %s by %s.", user.paymail, user.alias, admin.paymail)
+ })
+ }
+ })
+
+ t.Run("Step 5: The leader clients from one environment attempt to make internal transfers to users within their environment using the appropriate SPV Wallet API instance.", func(t *testing.T) {
+ tests := []struct {
+ name string
+ server *spvWalletServer
+ funds uint64
+ }{
+ {
+ server: spvWalletPG,
+ funds: 2,
+ name: fmt.Sprintf("%s should transfer 2 satoshis to the user %s", spvWalletPG.leader.paymail, spvWalletPG.user.paymail),
+ },
+ {
+ server: spvWalletSL,
+ funds: 3,
+ name: fmt.Sprintf("%s should transfer 3 satoshis to the user %s", spvWalletSL.leader.paymail, spvWalletSL.user.paymail),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ user := tc.server.user
+ leader := tc.server.leader
+
+ userBalance, err := user.balance(ctx)
+ assert.NoError(err, "Expected to get nil error after fetching balance by %s", user.paymail)
+
+ leaderBalance, err := leader.balance(ctx)
+ assert.NoError(err, "Expected to get nil error after fetching balance by %s", leader.paymail)
+
+ // when:
+ transaction, err := leader.transferFunds(ctx, user.paymail, tc.funds)
+
+ // then:
+ assert.NoError(err, "Transfer funds %d wasn't successful from %s to %s. Expect to get nil error", tc.funds, leader.paymail, user.paymail)
+
+ if assert.NotNil(transaction, "Expected to get non-nil transaction response after transfer funds %d from %s to %s", tc.funds, leader.paymail, user.paymail) {
+ // Verify sender's balance after the transaction
+ senderBalanceCorrect := assertBalanceAfterTransaction(ctx, t, leader, leaderBalance-tc.funds-transaction.Fee)
+
+ // Verify recipient's balance after the transaction
+ recipientBalanceCorrect := assertBalanceAfterTransaction(ctx, t, user, userBalance+tc.funds)
+
+ // Verify that the transaction appears in the recipient's transaction list
+ page, err := user.client.Transactions(ctx)
+ assert.NoError(err, "Failed to retrieve transactions for recipient %s. Expected to get nil error", user.paymail)
+
+ recipientTransactionsCorrect := assert.True(transactionsSlice(page.Content).Has(transaction.ID), "Transaction %s made by %s was not found in %s's transaction list.", transaction.ID, leader.paymail, user.paymail)
+
+ if senderBalanceCorrect && recipientBalanceCorrect && recipientTransactionsCorrect {
+ logSuccessOp(t, nil, "Transfer funds %d was successful from leader %s to user %s", tc.funds, leader.paymail, user.paymail)
+ }
+ }
+ })
+ }
+ })
+
+ t.Run("Step 6: The user from one env attempts to transfer funds to the user from external env using the appropriate SPV Wallet API instance", func(t *testing.T) {
+ tests := []struct {
+ name string
+ sender *user
+ recipient *user
+ funds uint64
+ }{
+ {
+ name: fmt.Sprintf("%s should transfer 2 satoshis to %s", spvWalletSL.user.paymail, spvWalletPG.user.paymail),
+ sender: spvWalletSL.user,
+ recipient: spvWalletPG.user,
+ funds: 2,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ sender := tc.sender
+ recipient := tc.recipient
+
+ recipientBalance, err := recipient.balance(ctx)
+ assert.NoError(err, "Expected to get nil error after fetching balance by %s", recipient.paymail)
+
+ senderBalance, err := sender.balance(ctx)
+ assert.NoError(err, "Expected to get nil error after fetching balance by %s", sender.paymail)
+
+ // when:
+ transaction, err := sender.transferFunds(ctx, recipient.paymail, tc.funds)
+
+ // then:
+ assert.NoError(err, "Transfer funds %d wasn't successful from sender %s to recipient %s. Expect to get nil error after making transaction, got error: %v", tc.funds, sender.paymail, recipient.paymail)
+
+ if assert.NotNil(transaction, "Expected to get non-nil transaction response after transfer funds %d from sender %s to recipient %s", tc.funds, sender.paymail, recipient.paymail) {
+ // Verify sender's balance after the transaction
+ senderBalanceCorrect := assertBalanceAfterTransaction(ctx, t, sender, senderBalance-tc.funds-transaction.Fee)
+
+ // Verify recipient's balance after the transaction
+ recipientBalanceCorrect := assertBalanceAfterTransaction(ctx, t, tc.recipient, recipientBalance+tc.funds)
+
+ if senderBalanceCorrect && recipientBalanceCorrect {
+ logSuccessOp(t, nil, "Transfer funds %d was successful from sender %s to recipient %s", tc.funds, sender.paymail, recipient.paymail)
+ }
+ }
+ })
+ }
+ })
+
+ t.Run("Step 7: The admin clients attempt to remove created actor paymails using the appropriate SPV Wallet API instance.", func(t *testing.T) {
+ tests := []struct {
+ name string
+ server *spvWalletServer
+ }{
+ {
+ name: fmt.Sprintf("%s should delete %s paymail record", spvWalletPG.admin.paymail, spvWalletPG.user.paymail),
+ server: spvWalletPG,
+ },
+ {
+ name: fmt.Sprintf("%s should delete %s paymail record", spvWalletSL.admin.paymail, spvWalletSL.user.paymail),
+ server: spvWalletSL,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // given:
+ admin := tc.server.admin
+ paymail := tc.server.user.paymail
+
+ // when:
+ err := admin.client.DeletePaymail(ctx, paymail)
+
+ // then:
+ assert.NoError(err, "Delete paymail %s wasn't successful by %s. Expect to get nil error, got error: %v", paymail, admin.paymail)
+ logSuccessOp(t, err, "Delete paymail %s was successful by %s", paymail, admin.paymail)
+ })
+ }
+ })
+}
+
+func initServers(t *testing.T) (*spvWalletServer, *spvWalletServer) {
+ const adminXPriv = "xprv9s21ZrQH143K3CbJXirfrtpLvhT3Vgusdo8coBritQ3rcS7Jy7sxWhatuxG5h2y1Cqj8FKmPp69536gmjYRpfga2MJdsGyBsnB12E19CESK"
+ const (
+ clientOneURL = "CLIENT_ONE_URL"
+ clientOneLeaderXPriv = "CLIENT_ONE_LEADER_XPRIV"
+ clientTwoURL = "CLIENT_TWO_URL"
+ clientTwoLeaderXPriv = "CLIENT_TWO_LEADER_XPRIV"
+ )
+ const (
+ alias1 = "UserSLRegressionTest"
+ alias2 = "UserPGRegressionTest"
+ )
+
+ spvWalletSL, err := initSPVWalletServer(alias1, &spvWalletServerConfig{
+ envURL: lookupEnvOrDefault(t, clientOneURL, ""),
+ envXPriv: lookupEnvOrDefault(t, clientOneLeaderXPriv, ""),
+ adminXPriv: adminXPriv,
+ })
+ require.NoError(t, err, "Step 1: Setup failed could not initialize the clients for env: %s", spvWalletSL.cfg.envURL)
+
+ spvWalletPG, err := initSPVWalletServer(alias2, &spvWalletServerConfig{
+ envURL: lookupEnvOrDefault(t, clientTwoURL, ""),
+ envXPriv: lookupEnvOrDefault(t, clientTwoLeaderXPriv, ""),
+ adminXPriv: adminXPriv,
+ })
+
+ require.NoError(t, err, "Step 1: Setup failed could not initialize the clients for env: %s", spvWalletPG.cfg.envURL)
+
+ return spvWalletPG, spvWalletSL
+}
diff --git a/regression_tests/spv_wallet_admin.go b/regression_tests/spv_wallet_admin.go
new file mode 100644
index 00000000..1aef9a54
--- /dev/null
+++ b/regression_tests/spv_wallet_admin.go
@@ -0,0 +1,37 @@
+package regressiontests
+
+import (
+ "fmt"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+)
+
+// admin represents an administrator within the SPV Wallet ecosystem.
+// It includes the administrator's private key (xPriv) and provides access
+// to the SPV Wallet's AdminAPI client for managing xPub and paymail-related operations.
+type admin struct {
+ xPriv string // The extended private key for the administrator.
+ client *wallet.AdminAPI // The API client for interacting with administrative functionalities in the SPV Wallet.
+ paymail string // The paymail addresses the administrator.
+ alias string // The alias of the administrator.
+}
+
+// setPaymail sets the admin's Paymail address to the given value.
+func (a *admin) setPaymail(s string) { a.paymail = a.alias + "@" + s }
+
+// initAdmin initializes a new admin within the SPV Wallet ecosystem.
+// It accepts the SPV Wallet API URL and the administrator's extended private key (xPriv) as input parameters.
+// The function initializes the wallet's AdminAPI client using the provided xPriv,
+// enabling the management of xPub and paymail-related operations.
+// On success, it returns the initialized admin and a nil error.
+// If the initialization fails, it returns a non-nil error with details of the failure.
+func initAdmin(url, xPriv string) (*admin, error) {
+ cfg := config.New(config.WithAddr(url))
+ client, err := wallet.NewAdminAPIWithXPriv(cfg, xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize admin API: %w", err)
+ }
+
+ return &admin{xPriv: xPriv, client: client, alias: "Admin"}, nil
+}
diff --git a/regression_tests/spv_wallet_server.go b/regression_tests/spv_wallet_server.go
new file mode 100644
index 00000000..512cfa7c
--- /dev/null
+++ b/regression_tests/spv_wallet_server.go
@@ -0,0 +1,82 @@
+package regressiontests
+
+import (
+ "fmt"
+)
+
+// spvWalletServerConfig contains configuration settings for initializing a SPVWalletAPI instance.
+// These include the environment URL and private keys required for admin and user operations.
+type spvWalletServerConfig struct {
+ envURL string // URL of the SPV Wallet API environment.
+ envXPriv string // Extended private key (xPriv) for the user account.
+ adminXPriv string // Extended private key (xPriv) for the admin account.
+}
+
+// Validate validates the spvWalletServerConfig.
+// It ensures that required fields like EnvURL and keys are not empty.
+func (c *spvWalletServerConfig) validate() error {
+ if c.envURL == "" {
+ return fmt.Errorf("validation failed: environment URL is required")
+ }
+
+ if c.adminXPriv == "" {
+ return fmt.Errorf("validation failed: admin xPriv is required")
+ }
+
+ if c.envXPriv == "" {
+ return fmt.Errorf("validation failed: leader xPriv is required")
+ }
+
+ return nil
+}
+
+// spvWalletServer represents the core API for interacting with the SPV Wallet ecosystem.
+// It holds configuration and client instances for admin, user, and leader operations.
+type spvWalletServer struct {
+ cfg *spvWalletServerConfig // Configuration for the SPV Wallet API Config (e.g., environment URL, keys).
+ admin *admin // Admin client for performing administrative tasks like creating xPubs and paymails.
+ user *user // User client for standard wallet operations, such as transactions and balance retrieval.
+ leader *user // Leader user client with potentially elevated privileges, managing broader wallet operations.
+}
+
+// setPaymailDomains sets SPV Wallet server clients to have their paymail addresses with the given domain address part.
+func (s *spvWalletServer) setPaymailDomains(domain string) {
+ type paymailSetter interface{ setPaymail(string) }
+
+ clients := []paymailSetter{s.leader, s.admin, s.user}
+ for _, client := range clients {
+ client.setPaymail(domain)
+ }
+}
+
+// initSPVWalletServer initializes the spvWalletAPI with Admin, Leader, and User clients.
+// It accepts user alias and spvWalletServerConfig to be created as input parameters.
+// On success, it returns an initialized SPVWalletAPI instance and nil error.
+// If initialization of any component fails, a non-nil error is returned.
+func initSPVWalletServer(alias string, cfg *spvWalletServerConfig) (*spvWalletServer, error) {
+ if err := cfg.validate(); err != nil {
+ return nil, fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ admin, err := initAdmin(cfg.envURL, cfg.adminXPriv)
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize admin: %w", err)
+ }
+
+ leader, err := initUserWithXPriv("Leader", cfg.envURL, cfg.envXPriv)
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize leader user: %w", err)
+ }
+
+ user, err := initUser(alias, cfg.envURL)
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize user %q: %w", alias, err)
+ }
+
+ return &spvWalletServer{
+ cfg: cfg,
+ admin: admin,
+ leader: leader,
+ user: user,
+ }, nil
+}
diff --git a/regression_tests/spv_wallet_user.go b/regression_tests/spv_wallet_user.go
new file mode 100644
index 00000000..5120962e
--- /dev/null
+++ b/regression_tests/spv_wallet_user.go
@@ -0,0 +1,123 @@
+package regressiontests
+
+import (
+ "context"
+ "fmt"
+
+ wallet "github.com/bitcoin-sv/spv-wallet-go-client"
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+)
+
+// transactionsSlice represents a slice of response.Transaction objects.
+type transactionsSlice []*response.Transaction
+
+// Has checks if a transaction with the specified ID exists in the transactions slice.
+// It returns true if a transaction with the given ID is found, and false otherwise.
+func (tt transactionsSlice) Has(id string) bool {
+ for _, t := range tt {
+ if t.ID == id {
+ return true
+ }
+ }
+
+ return false
+}
+
+// user represents an individual user within the SPV Wallet ecosystem.
+// It includes details like the alias, private key (xPriv), public key (xPub), and paymail address.
+// The user struct also utilizes the wallet's UserAPI client to interact with the SPV Wallet API
+// for transaction-related operations.
+type user struct {
+ alias string // The unique alias for the user.
+ xPriv string // The extended private key for the user.
+ xPub string // The extended public key for the user.
+ paymail string // The paymail address associated with the user.
+ client *wallet.UserAPI // The API client for interacting with the SPV Wallet.
+}
+
+// setPaymail sets the user's Paymail address with the given domain.
+func (u *user) setPaymail(domain string) { u.paymail = u.alias + "@" + domain }
+
+// transferFunds sends a specified amount of satoshis to a recipient's paymail.
+// It accepts a context parameter to manage cancellation and timeouts.
+// On success, it returns the transaction representing the fund transfer and a nil error.
+// If the actor has insufficient funds or the API call fails, it returns a non-nil error.
+func (u *user) transferFunds(ctx context.Context, paymail string, funds uint64) (*response.Transaction, error) {
+ balance, err := u.balance(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("balance failed: %w", err)
+ }
+ if balance < funds {
+ return nil, fmt.Errorf("insufficient balance: %d available, %d required", balance, funds)
+ }
+
+ recipient := commands.Recipients{To: paymail, Satoshis: funds}
+ transaction, err := u.client.SendToRecipients(ctx, &commands.SendToRecipients{
+ Recipients: []*commands.Recipients{&recipient},
+ Metadata: map[string]any{"description": "regression-test"},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("could not transfer funds to %s: %w", paymail, err)
+ }
+
+ return transaction, nil
+}
+
+// balance retrieves the current satoshi balance for given actor.
+// It accepts a context parameter to manage cancellation and timeouts.
+// On success, it returns the current balance and a nil error.
+// If the API call fails, it returns a non-nil error with details of the failure.
+func (u *user) balance(ctx context.Context) (uint64, error) {
+ xPub, err := u.client.XPub(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("could not retrieve xPub: %w", err)
+ }
+
+ return xPub.CurrentBalance, nil
+}
+
+// initUser initializes a new user within the SPV Wallet ecosystem.
+// It accepts the alias and SPV Wallet API URL as input parameters.
+// The function generates a random pair of wallet keys (xPub, xPriv) and uses the xPriv key
+// to initialize the wallet's client, enabling transaction-related operations.
+// On success, it returns the initialized user and a nil error.
+// If user initialization fails, it returns a non-nil error with details of the failure.
+func initUser(alias, url string) (*user, error) {
+ keys, err := walletkeys.RandomKeys()
+ if err != nil {
+ return nil, fmt.Errorf("could not generate random keys: %w", err)
+ }
+
+ client, err := wallet.NewUserAPIWithXPriv(config.New(config.WithAddr(url)), keys.XPriv())
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize user API for alias %q: %w", alias, err)
+ }
+
+ return &user{
+ alias: alias,
+ xPriv: keys.XPriv(),
+ xPub: keys.XPub(),
+ client: client,
+ }, nil
+}
+
+// initUserWithXPriv initializes a new user within the SPV Wallet ecosystem.
+// It accepts the alias, xPriv and SPV Wallet API URL as input parameters.
+// The function nitializes the wallet's client, enabling transaction-related operations.
+// On success, it returns the initialized user and a nil error.
+// If user initialization fails, it returns a non-nil error with details of the failure.
+func initUserWithXPriv(alias, url, xPriv string) (*user, error) {
+ client, err := wallet.NewUserAPIWithXPriv(config.New(config.WithAddr(url)), xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("could not initialize user API for alias %q: %w", alias, err)
+ }
+
+ return &user{
+ alias: alias,
+ xPriv: xPriv,
+ client: client,
+ }, nil
+}
diff --git a/regression_tests/test_helpers.go b/regression_tests/test_helpers.go
new file mode 100644
index 00000000..5f89ef15
--- /dev/null
+++ b/regression_tests/test_helpers.go
@@ -0,0 +1,50 @@
+package regressiontests
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// assertBalanceAfterTransaction checks if a user's balance matches the expected value after a transaction.
+// This function is intended for use in tests and relies on the `assert` library for assertions.
+// It calls t.Helper() to ensure that errors are reported at the caller's location.
+func assertBalanceAfterTransaction(ctx context.Context, t *testing.T, user *user, expectedBalance uint64) bool {
+ t.Helper()
+
+ actualBalance, err := user.balance(ctx)
+ if !assert.NoError(t, err, "Failed to retrieve balance for %s", user.paymail) {
+ return false
+ }
+
+ return assert.Equal(t, expectedBalance, actualBalance, "Balance mismatch for %s.", user.paymail)
+}
+
+// lookupEnvOrDefault retrieves the value of the specified environment variable.
+// If the variable is not set, it returns the provided default value.
+// This function is intended for use in tests. It calls t.Helper()
+// to ensure that errors are reported at the caller's location.
+func lookupEnvOrDefault(t *testing.T, env string, defaultValue string) string {
+ t.Helper()
+
+ v, ok := os.LookupEnv(env)
+ if !ok {
+ t.Logf("Environment variable %s not set, using default: %s", env, defaultValue)
+ return defaultValue
+ }
+ return v
+}
+
+// logSuccessOp logs a success message if there is no error.
+// This function is intended for use in tests. It calls t.Helper()
+// to ensure that errors are reported at the caller's location.
+func logSuccessOp(t *testing.T, err error, format string, args ...any) {
+ t.Helper()
+
+ if err != nil {
+ return
+ }
+ t.Logf(format, args...)
+}
diff --git a/regression_tests/utils.go b/regression_tests/utils.go
deleted file mode 100644
index 3ce11588..00000000
--- a/regression_tests/utils.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package regressiontests
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "regexp"
- "strings"
-
- walletclient "github.com/bitcoin-sv/spv-wallet-go-client"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/bitcoin-sv/spv-wallet/models/filter"
-)
-
-const (
- atSign = "@"
- domainPrefix = "https://"
-
- ClientOneURLEnvVar = "CLIENT_ONE_URL"
- ClientTwoURLEnvVar = "CLIENT_TWO_URL"
- ClientOneLeaderXPrivEnvVar = "CLIENT_ONE_LEADER_XPRIV"
- ClientTwoLeaderXPrivEnvVar = "CLIENT_TWO_LEADER_XPRIV"
-)
-
-var (
- explicitHTTPURLRegex = regexp.MustCompile(`^https?://`)
- errEmptyXPrivEnvVariables = errors.New("missing xpriv variables")
-)
-
-type regressionTestUser struct {
- XPriv string `json:"xpriv"`
- XPub string `json:"xpub"`
- Paymail string `json:"paymail"`
-}
-
-type regressionTestConfig struct {
- ClientOneURL string
- ClientTwoURL string
- ClientOneLeaderXPriv string
- ClientTwoLeaderXPriv string
-}
-
-// getEnvVariables retrieves the environment variables needed for the regression tests.
-func getEnvVariables() (*regressionTestConfig, error) {
- rtConfig := regressionTestConfig{
- ClientOneURL: os.Getenv(ClientOneURLEnvVar),
- ClientTwoURL: os.Getenv(ClientTwoURLEnvVar),
- ClientOneLeaderXPriv: os.Getenv(ClientOneLeaderXPrivEnvVar),
- ClientTwoLeaderXPriv: os.Getenv(ClientTwoLeaderXPrivEnvVar),
- }
-
- if rtConfig.ClientOneLeaderXPriv == "" || rtConfig.ClientTwoLeaderXPriv == "" {
- return nil, errEmptyXPrivEnvVariables
- }
- if rtConfig.ClientOneURL == "" || rtConfig.ClientTwoURL == "" {
- rtConfig.ClientOneURL = "http://localhost:3003"
- rtConfig.ClientTwoURL = "http://localhost:3003"
- }
-
- rtConfig.ClientOneURL = addPrefixIfNeeded(rtConfig.ClientOneURL)
- rtConfig.ClientTwoURL = addPrefixIfNeeded(rtConfig.ClientTwoURL)
-
- return &rtConfig, nil
-}
-
-// getPaymailDomain retrieves the shared configuration from the SPV Wallet.
-func getPaymailDomain(ctx context.Context, xpriv string, clientUrl string) (string, error) {
- wc, err := walletclient.NewWithXPriv(clientUrl, xpriv)
- if err != nil {
- return "", err
- }
- sharedConfig, err := wc.GetSharedConfig(ctx)
- if err != nil {
- return "", err
- }
- if len(sharedConfig.PaymailDomains) != 1 {
- return "", fmt.Errorf("expected 1 paymail domain, got %d", len(sharedConfig.PaymailDomains))
- }
- return sharedConfig.PaymailDomains[0], nil
-}
-
-// createUser creates a set of keys and new paymail in the SPV Wallet.
-func createUser(ctx context.Context, paymail string, paymailDomain string, instanceUrl string, adminXPriv string) (*regressionTestUser, error) {
- keys, err := xpriv.Generate()
- if err != nil {
- return nil, err
- }
-
- user := ®ressionTestUser{
- XPriv: keys.XPriv(),
- XPub: keys.XPub().String(),
- Paymail: preparePaymail(paymail, paymailDomain),
- }
-
- adminClient, err := walletclient.NewWithAdminKey(instanceUrl, adminXPriv)
- if err != nil {
- return nil, err
- }
-
- if err := adminClient.AdminNewXpub(ctx, user.XPub, map[string]any{"some_metadata": "remove"}); err != nil {
- return nil, err
- }
-
- _, err = adminClient.AdminCreatePaymail(ctx, user.XPub, user.Paymail, "Regression tests", "")
- if err != nil {
- return nil, err
- }
-
- return user, nil
-}
-
-// removeRegisteredPaymail soft deletes paymail from the SPV Wallet.
-func removeRegisteredPaymail(ctx context.Context, paymail string, instanceURL string, adminXPriv string) error {
- adminClient, err := walletclient.NewWithAdminKey(instanceURL, adminXPriv)
- if err != nil {
- return err
- }
- err = adminClient.AdminDeletePaymail(ctx, paymail)
- if err != nil {
- return err
- }
- return nil
-}
-
-// getBalance retrieves the balance from the SPV Wallet.
-func getBalance(ctx context.Context, fromInstance string, fromXPriv string) (int, error) {
- client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv)
- if err != nil {
- return -1, err
- }
- xpubInfo, err := client.GetXPub(ctx)
- if err != nil {
- return -1, err
- }
- return int(xpubInfo.CurrentBalance), nil
-}
-
-// getTransactions retrieves the transactions from the SPV Wallet.
-func getTransactions(ctx context.Context, fromInstance string, fromXPriv string) ([]*models.Transaction, error) {
- client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv)
- if err != nil {
- return nil, err
- }
-
- metadata := map[string]any{}
- conditions := filter.TransactionFilter{}
- queryParams := filter.QueryParams{}
-
- txs, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams)
- if err != nil {
- return nil, err
- }
- return txs, nil
-}
-
-// sendFunds sends funds from one paymail to another.
-func sendFunds(ctx context.Context, fromInstance string, fromXPriv string, toPaymail string, howMuch int) (*models.Transaction, error) {
- client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv)
- if err != nil {
- return nil, err
- }
-
- balance, err := getBalance(ctx, fromInstance, fromXPriv)
- if err != nil {
- return nil, err
- }
- if balance < howMuch {
- return nil, fmt.Errorf("insufficient funds: %d", balance)
- }
-
- recipient := walletclient.Recipients{To: toPaymail, Satoshis: uint64(howMuch)}
- recipients := []*walletclient.Recipients{&recipient}
- metadata := map[string]any{
- "description": "regression-test",
- }
-
- transaction, err := client.SendToRecipients(ctx, recipients, metadata)
- if err != nil {
- return nil, err
- }
- return transaction, nil
-}
-
-// preparePaymail prepares the paymail address by combining the alias and domain.
-func preparePaymail(paymailAlias string, domain string) string {
- if isValidURL(domain) {
- splitedDomain := strings.SplitAfter(domain, "//")
- domain = splitedDomain[1]
- }
- url := paymailAlias + atSign + domain
- return url
-}
-
-// addPrefixIfNeeded adds the HTTPS prefix to the URL if it is missing.
-func addPrefixIfNeeded(url string) string {
- if !isValidURL(url) {
- return domainPrefix + url
- }
- return url
-}
-
-// isValidURL validates the URL if it has http or https prefix.
-func isValidURL(rawURL string) bool {
- return explicitHTTPURLRegex.MatchString(rawURL)
-}
diff --git a/search.go b/search.go
deleted file mode 100644
index aa23dcb0..00000000
--- a/search.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- "github.com/bitcoin-sv/spv-wallet/models/filter"
-)
-
-// SearchRequester is a function that sends a request to the server and returns the response.
-type SearchRequester func(ctx context.Context, method string, path string, rawJSON []byte, xPriv *bip32.ExtendedKey, sign bool, responseJSON interface{}) error
-
-// Search prepares and sends a search request to the server.
-func Search[TFilter any, TResp any](
- ctx context.Context,
- method string,
- path string,
- xPriv *bip32.ExtendedKey,
- f *TFilter,
- metadata map[string]any,
- queryParams *filter.QueryParams,
- requester SearchRequester,
-) (TResp, error) {
- jsonStr, err := json.Marshal(filter.SearchModel[TFilter]{
- ConditionsModel: filter.ConditionsModel[TFilter]{
- Conditions: f,
- Metadata: metadata,
- },
- QueryParams: queryParams,
- })
- var resp TResp // before initialization, this var is empty slice or nil so it can be returned in case of error
- if err != nil {
- return resp, WrapError(err)
- }
-
- if err := requester(ctx, method, path, jsonStr, xPriv, true, &resp); err != nil {
- return resp, err
- }
-
- return resp, nil
-}
-
-// Count prepares and sends a count request to the server.
-func Count[TFilter any](
- ctx context.Context,
- method string,
- path string,
- xPriv *bip32.ExtendedKey,
- f *TFilter,
- metadata map[string]any,
- requester SearchRequester,
-) (int64, error) {
- jsonStr, err := json.Marshal(filter.ConditionsModel[TFilter]{
- Conditions: f,
- Metadata: metadata,
- })
- if err != nil {
- return 0, WrapError(err)
- }
- var count int64
- if err := requester(ctx, method, path, jsonStr, xPriv, true, &count); err != nil {
- return 0, err
- }
-
- return count, nil
-}
-
-// Optional returns a pointer to provided value, it's necessary to define "optional" fields in filters
-func Optional[T any](val T) *T {
- return &val
-}
diff --git a/sync_merkleroots.go b/sync_merkleroots.go
deleted file mode 100644
index a69ee2ab..00000000
--- a/sync_merkleroots.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package walletclient
-
-import (
- "context"
- "fmt"
- "net/http"
- "strings"
-
- "github.com/bitcoin-sv/spv-wallet/models"
-)
-
-// MerkleRootsRepository is an interface responsible for storing synchronized MerkleRoots and retrieving the last evaluation key from the database.
-type MerkleRootsRepository interface {
- // GetLastMerkleRoot should return the Merkle root with the highest height from your memory, or undefined if empty.
- GetLastMerkleRoot() string
- // SaveMerkleRoots should store newly synced merkle roots into your storage;
- // NOTE: items are sorted in ascending order by block height.
- SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error
-}
-
-// SyncMerkleRoots syncs merkleroots known to spv-wallet with the client database
-// If timeout is needed pass context.WithTimeout() as ctx param
-func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error {
- lastEvaluatedKey := repo.GetLastMerkleRoot()
- requestPath := "merkleroots"
- lastEvaluatedKeyQuery := ""
- previousLastEvaluatedKey := lastEvaluatedKey
-
- if lastEvaluatedKey != "" {
- lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", lastEvaluatedKey)
- }
-
- for {
- select {
- case <-ctx.Done():
- return ErrSyncMerkleRootsTimeout
- default:
- url := fmt.Sprintf("/%s%s", requestPath, lastEvaluatedKeyQuery)
-
- var merkleRootsResponse models.ExclusiveStartKeyPage[[]models.MerkleRoot]
-
- err := wc.doHTTPRequest(ctx, http.MethodGet, url, nil, wc.xPriv, true, &merkleRootsResponse)
-
- if err != nil {
- // In case if the context deadline exceeds its limit during http request, httpClient
- // cancels the request wrapping it as spverror, so we need to check if the message
- // is the same as context deadline exceeded error
- if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
- return ErrSyncMerkleRootsTimeout
- }
- return WrapError(err)
- }
-
- lastEvaluatedKey = merkleRootsResponse.Page.LastEvaluatedKey
- if lastEvaluatedKey != "" && previousLastEvaluatedKey == lastEvaluatedKey {
- return ErrStaleLastEvaluatedKey
- }
-
- err = repo.SaveMerkleRoots(merkleRootsResponse.Content)
- if err != nil {
- return err
- }
-
- previousLastEvaluatedKey = lastEvaluatedKey
- if previousLastEvaluatedKey == "" {
- return nil
- }
-
- lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", previousLastEvaluatedKey)
- }
- }
-}
diff --git a/sync_merkleroots_test.go b/sync_merkleroots_test.go
deleted file mode 100644
index 5b4c7829..00000000
--- a/sync_merkleroots_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package walletclient
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/stretchr/testify/require"
-)
-
-func TestSyncMerkleRoots(t *testing.T) {
-
- t.Run("Should properly sync database when empty", func(t *testing.T) {
- // setup
- server := fixtures.MockMerkleRootsAPIResponseNormal()
- defer server.Close()
-
- // given
- repo := fixtures.CreateRepository([]models.MerkleRoot{})
- client, err := NewWithXPriv(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
-
- // when
- err = client.SyncMerkleRoots(context.Background(), repo)
-
- // then
- require.NoError(t, err)
- require.Len(t, repo.MerkleRoots, len(fixtures.MockedSPVWalletData))
- require.Equal(t, fixtures.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1])
- })
-
- t.Run("Should properly sync database when partially filled", func(t *testing.T) {
- // setup
- server := fixtures.MockMerkleRootsAPIResponseNormal()
- defer server.Close()
-
- // given
- repo := fixtures.CreateRepository([]models.MerkleRoot{
- {
- MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
- BlockHeight: 0,
- },
- {
- MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098",
- BlockHeight: 1,
- },
- {
- MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5",
- BlockHeight: 2,
- },
- })
- client, err := NewWithXPriv(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
-
- // when
- err = client.SyncMerkleRoots(context.Background(), repo)
-
- // then
- require.NoError(t, err)
- require.Len(t, repo.MerkleRoots, len(fixtures.MockedSPVWalletData))
- require.Equal(t, fixtures.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1])
- })
-
- t.Run("Should fail sync merkleroots due to the time out", func(t *testing.T) {
- // setup
- server := fixtures.MockMerkleRootsAPIResponseDelayed()
- defer server.Close()
-
- // given
- repo := fixtures.CreateRepository([]models.MerkleRoot{})
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
- defer cancel()
-
- client, err := NewWithXPriv(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
-
- // when
- err = client.SyncMerkleRoots(ctx, repo)
-
- // then
- require.ErrorIs(t, err, ErrSyncMerkleRootsTimeout)
- })
-
- t.Run("Should fail sync merkleroots due to last evaluated key being the same in the response", func(t *testing.T) {
- // setup
- server := fixtures.MockMerkleRootsAPIResponseStale()
- defer server.Close()
-
- // given
- repo := fixtures.CreateRepository([]models.MerkleRoot{})
- client, err := NewWithXPriv(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
-
- // when
- err = client.SyncMerkleRoots(context.Background(), repo)
-
- // then
- require.ErrorIs(t, err, ErrStaleLastEvaluatedKey)
- })
-}
diff --git a/totp.go b/totp.go
deleted file mode 100644
index babeb08a..00000000
--- a/totp.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package walletclient
-
-import (
- "encoding/base32"
- "encoding/hex"
- "time"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
- "github.com/bitcoin-sv/spv-wallet-go-client/utils"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/pquerna/otp"
- "github.com/pquerna/otp/totp"
-)
-
-const (
- // TotpDefaultPeriod - Default number of seconds a TOTP is valid for.
- TotpDefaultPeriod uint = 30
- // TotpDefaultDigits - Default TOTP length
- TotpDefaultDigits uint = 2
-)
-
-/*
-Basic flow:
-Alice generates passcodeForBob with (sharedSecret+(contact.Paymail as bobPaymail))
-Alice sends passcodeForBob to Bob (e.g. via email)
-Bob validates passcodeForBob with (sharedSecret+(requesterPaymail as bobPaymail))
-The (sharedSecret+paymail) is a "directedSecret". This ensures that passcodeForBob-from-Alice != passcodeForAlice-from-Bob.
-The flow looks the same for Bob generating passcodeForAlice.
-*/
-
-// GenerateTotpForContact creates one time-based one-time password based on secret shared between the user and the contact
-func (b *WalletClient) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) {
- sharedSecret, err := makeSharedSecret(b, contact)
- if err != nil {
- return "", err
- }
-
- opts := getTotpOpts(period, digits)
- return totp.GenerateCodeCustom(directedSecret(sharedSecret, contact.Paymail), time.Now(), *opts)
-}
-
-// ValidateTotpForContact validates one time-based one-time password based on secret shared between the user and the contact
-func (b *WalletClient) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) (bool, error) {
- sharedSecret, err := makeSharedSecret(b, contact)
- if err != nil {
- return false, err
- }
-
- opts := getTotpOpts(period, digits)
- return totp.ValidateCustom(passcode, directedSecret(sharedSecret, requesterPaymail), time.Now(), *opts)
-}
-
-func makeSharedSecret(b *WalletClient, c *models.Contact) ([]byte, error) {
- privKey, pubKey, err := getSharedSecretFactors(b, c)
- if err != nil {
- return nil, err
- }
-
- x, _ := ec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes())
- return x.Bytes(), nil
-}
-
-func getTotpOpts(period, digits uint) *totp.ValidateOpts {
- if period == 0 {
- period = TotpDefaultPeriod
- }
-
- if digits == 0 {
- digits = TotpDefaultDigits
- }
-
- return &totp.ValidateOpts{
- Period: period,
- Digits: otp.Digits(digits),
- }
-}
-
-func getSharedSecretFactors(b *WalletClient, c *models.Contact) (*ec.PrivateKey, *ec.PublicKey, error) {
- if b.xPriv == nil {
- return nil, nil, ErrMissingXpriv
- }
-
- xpriv, err := deriveXprivForPki(b.xPriv)
- if err != nil {
- return nil, nil, err
- }
-
- privKey, err := xpriv.ECPrivKey()
- if err != nil {
- return nil, nil, err
- }
-
- pubKey, err := convertPubKey(c.PubKey)
- if err != nil {
- return nil, nil, ErrContactPubKeyInvalid
- }
-
- return privKey, pubKey, nil
-}
-
-func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) {
- // PKI derivation path: m/0/0/0
- // NOTICE: we currently do not support PKI rotation; however, adjustments will be made if and when we decide to implement it
-
- pkiXpriv, err := bip32.GetHDKeyByPath(xpriv, utils.ChainExternal, 0)
- if err != nil {
- return nil, err
- }
-
- return pkiXpriv.Child(0)
-}
-
-func convertPubKey(pubKey string) (*ec.PublicKey, error) {
- hex, err := hex.DecodeString(pubKey)
- if err != nil {
- return nil, err
- }
-
- return ec.ParsePubKey(hex)
-}
-
-// directedSecret appends a paymail to the secret and encodes it into base32 string
-func directedSecret(sharedSecret []byte, paymail string) string {
- return base32.StdEncoding.EncodeToString(append(sharedSecret, []byte(paymail)...))
-}
diff --git a/totp_test.go b/totp_test.go
deleted file mode 100644
index f63bb803..00000000
--- a/totp_test.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package walletclient
-
-import (
- "encoding/hex"
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/stretchr/testify/require"
-)
-
-func TestGenerateTotpForContact(t *testing.T) {
- t.Run("success", func(t *testing.T) {
- // given
- sut, err := NewWithXPriv("localhost:3001", fixtures.XPrivString)
- require.NoError(t, err)
- require.NotNil(t, sut.xPriv)
-
- contact := models.Contact{PubKey: fixtures.PubKey}
- // when
- pass, err := sut.GenerateTotpForContact(&contact, 30, 2)
-
- // then
- require.NoError(t, err)
- require.Len(t, pass, 2)
- })
-
- t.Run("WalletClient without xPriv - returns error", func(t *testing.T) {
- // given
- sut, err := NewWithXPub("localhost:3001", fixtures.XPubString)
- require.NoError(t, err)
- require.NotNil(t, sut.xPub)
- // when
- _, err = sut.GenerateTotpForContact(nil, 30, 2)
-
- // then
- require.ErrorIs(t, err, ErrMissingXpriv)
- })
-
- t.Run("contact has invalid PubKey - returns error", func(t *testing.T) {
- // given
- sut, err := NewWithXPriv("localhost:3001", fixtures.XPrivString)
- require.NoError(t, err)
- require.NotNil(t, sut.xPriv)
-
- contact := models.Contact{PubKey: "invalid-pk-format"}
- // when
- _, err = sut.GenerateTotpForContact(&contact, 30, 2)
-
- // then
- require.ErrorIs(t, err, ErrContactPubKeyInvalid)
-
- })
-}
-
-func TestValidateTotpForContact(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // This handler could be adjusted depending on the expected API endpoints
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("123456")) // Simulate a TOTP response for any requests
- }))
- defer server.Close()
-
- serverURL := fmt.Sprintf("%s/v1", server.URL)
- t.Run("success", func(t *testing.T) {
- aliceKeys, err := xpriv.Generate()
- require.NoError(t, err)
- bobKeys, err := xpriv.Generate()
- require.NoError(t, err)
-
- // Set up the WalletClient for Alice and Bob
- clientAlice, err := NewWithXPriv(serverURL, aliceKeys.XPriv())
- require.NoError(t, err)
- require.NotNil(t, clientAlice.xPriv)
- clientBob, err := NewWithXPriv(serverURL, bobKeys.XPriv())
- require.NoError(t, err)
- require.NotNil(t, clientBob.xPriv)
-
- aliceContact := &models.Contact{
- PubKey: makeMockPKI(aliceKeys.XPub().String()),
- Paymail: "bob@example.com",
- }
-
- bobContact := &models.Contact{
- PubKey: makeMockPKI(bobKeys.XPub().String()),
- Paymail: "bob@example.com",
- }
-
- // Generate and validate TOTP
- passcode, err := clientAlice.GenerateTotpForContact(bobContact, 3600, 6)
- require.NoError(t, err)
- result, err := clientBob.ValidateTotpForContact(aliceContact, passcode, bobContact.Paymail, 3600, 6)
- require.NoError(t, err)
- require.True(t, result)
- })
-
- t.Run("contact has invalid PubKey - returns error", func(t *testing.T) {
- sut, err := NewWithXPriv(serverURL, fixtures.XPrivString)
- require.NoError(t, err)
-
- invalidContact := &models.Contact{
- PubKey: "invalid_pub_key_format",
- Paymail: "invalid@example.com",
- }
-
- _, err = sut.ValidateTotpForContact(invalidContact, "123456", "someone@example.com", 3600, 6)
- require.Error(t, err)
- require.Contains(t, err.Error(), "contact's PubKey is invalid")
- })
-}
-
-func makeMockPKI(xpub string) string {
- xPub, _ := bip32.NewKeyFromString(xpub)
- var err error
- for i := 0; i < 3; i++ { //magicNumberOfInheritance is 3 -> 2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI
- xPub, err = xPub.Child(0)
- if err != nil {
- panic(err)
- }
- }
-
- pubKey, err := xPub.ECPubKey()
- if err != nil {
- panic(err)
- }
-
- return hex.EncodeToString(pubKey.Compressed())
-}
diff --git a/transactions_test.go b/transactions_test.go
deleted file mode 100644
index 7bc61755..00000000
--- a/transactions_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/bitcoin-sv/spv-wallet/models/filter"
- "github.com/stretchr/testify/require"
-)
-
-func TestTransactions(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/v1/transaction":
- handleTransaction(w, r)
- case "/v1/transaction/search":
- json.NewEncoder(w).Encode([]*models.Transaction{fixtures.Transaction})
- case "/v1/transaction/count":
- json.NewEncoder(w).Encode(1)
- case "/v1/transaction/record":
- if r.Method == http.MethodPost {
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(fixtures.Transaction)
- } else {
- w.WriteHeader(http.StatusMethodNotAllowed)
- }
- default:
- w.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
-
- client, err := NewWithXPriv(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
- require.NotNil(t, client.xPriv)
-
- t.Run("GetTransaction", func(t *testing.T) {
- tx, err := client.GetTransaction(context.Background(), fixtures.Transaction.ID)
- require.NoError(t, err)
- require.Equal(t, fixtures.Transaction, tx)
- })
-
- t.Run("GetTransactions", func(t *testing.T) {
- conditions := &filter.TransactionFilter{
- Fee: Optional(uint64(97)),
- TotalValue: Optional(uint64(6955)),
- }
- txs, err := client.GetTransactions(context.Background(), conditions, fixtures.TestMetadata, nil)
- require.NoError(t, err)
- require.Equal(t, []*models.Transaction{fixtures.Transaction}, txs)
- })
-
- t.Run("GetTransactionsCount", func(t *testing.T) {
- count, err := client.GetTransactionsCount(context.Background(), nil, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, int64(1), count)
- })
-
- t.Run("RecordTransaction", func(t *testing.T) {
- tx, err := client.RecordTransaction(context.Background(), fixtures.Transaction.Hex, "", fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Transaction, tx)
- })
-
- t.Run("UpdateTransactionMetadata", func(t *testing.T) {
- tx, err := client.UpdateTransactionMetadata(context.Background(), fixtures.Transaction.ID, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Transaction, tx)
- })
-
- t.Run("SendToRecipients", func(t *testing.T) {
- recipients := []*Recipients{{
- OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn,
- Satoshis: 1000,
- To: fixtures.Destination.Address,
- }}
- tx, err := client.SendToRecipients(context.Background(), recipients, fixtures.TestMetadata)
- require.NoError(t, err)
- require.Equal(t, fixtures.Transaction, tx)
- })
-}
-
-func handleTransaction(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet, http.MethodPost:
- if err := json.NewEncoder(w).Encode(fixtures.Transaction); err != nil {
- http.Error(w, "Failed to encode response", http.StatusInternalServerError)
- }
- case http.MethodPatch:
- var input map[string]interface{}
- if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
- w.WriteHeader(http.StatusBadRequest)
- if err := json.NewEncoder(w).Encode(map[string]string{"error": "bad request"}); err != nil {
- http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
- }
- return
- }
- response := fixtures.Transaction
- if metadata, ok := input["metadata"].(map[string]interface{}); ok {
- response.Metadata = metadata
- }
- if id, ok := input["id"].(string); ok {
- response.ID = id
- }
- w.Header().Set("Content-Type", "application/json")
- if err := json.NewEncoder(w).Encode(response); err != nil {
- http.Error(w, "Failed to encode response", http.StatusInternalServerError)
- }
- default:
- w.WriteHeader(http.StatusMethodNotAllowed)
- }
-}
diff --git a/user_api.go b/user_api.go
new file mode 100644
index 00000000..9f972f16
--- /dev/null
+++ b/user_api.go
@@ -0,0 +1,541 @@
+package spvwallet
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/commands"
+ "github.com/bitcoin-sv/spv-wallet-go-client/config"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/configs"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/accesskeys"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/paymails"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/xpubs"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/constants"
+ "github.com/bitcoin-sv/spv-wallet-go-client/internal/restyutil"
+ "github.com/bitcoin-sv/spv-wallet-go-client/queries"
+ "github.com/bitcoin-sv/spv-wallet/models"
+ "github.com/bitcoin-sv/spv-wallet/models/filter"
+ "github.com/bitcoin-sv/spv-wallet/models/response"
+ "github.com/go-resty/resty/v2"
+)
+
+// UserAPI provides methods for interacting with user-related APIs.
+// It abstracts the details of HTTP request and response handling,
+// simplifying interaction with the endpoints.
+//
+// A zero-value UserAPI is not usable. Use one of the constructors
+// (e.g., NewUserAPIWithAccessKey, NewUserAPIWithXPriv, or NewUserAPIWithXPub)
+// to create a properly initialized instance.
+//
+// UserAPI methods may return wrapped errors, including models.SPVError or
+// ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API.
+type UserAPI struct {
+ xpubAPI *xpubs.API
+ accessKeyAPI *accesskeys.API
+ configsAPI *configs.API
+ merkleRootsAPI *merkleroots.API
+ contactsAPI *contacts.API
+ invitationsAPI *invitations.API
+ transactionsAPI *transactions.API
+ utxosAPI *utxos.API
+ paymailsAPI *paymails.API
+ totpAPI *totp.API //only available when using xPriv
+}
+
+// Contacts retrieves a paginated list of user contacts from the user contacts API.
+//
+// The response includes contact data along with pagination details, such as the
+// current page, sort order, and sortBy field. Optional query parameters can be
+// provided using query options. The result is unmarshaled into a *queries.ContactsPage.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (u *UserAPI) Contacts(ctx context.Context, contactOpts ...queries.QueryOption[filter.ContactFilter]) (*queries.ContactsPage, error) {
+ res, err := u.contactsAPI.Contacts(ctx, contactOpts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserContactsAPI, "retrieve contacts page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// ContactWithPaymail retrieves a user contact by their paymail address.
+// The response is unmarshaled into a *response.Contact.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (u *UserAPI) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) {
+ res, err := u.contactsAPI.ContactWithPaymail(ctx, paymail)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserContactsAPI, "retrieve contact with paymail", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// UpsertContact adds or updates a user contact via the user contacts API.
+// The response is unmarshaled into a *response.Contact.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (u *UserAPI) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) {
+ res, err := u.contactsAPI.UpsertContact(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserContactsAPI, "upsert contact", err).FormatPutErr()
+ }
+
+ return res, nil
+}
+
+// RemoveContact deletes a user contact with the given paymail via the user contacts API.
+// Returns an error if the API request fails or the response cannot be decoded.
+// A nil error indicates the deleting contact was successful.
+func (u *UserAPI) RemoveContact(ctx context.Context, paymail string) error {
+ err := u.contactsAPI.RemoveContact(ctx, paymail)
+ if err != nil {
+ return errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, "remove contact", err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// ConfirmContact checks the TOTP code and if it's ok, confirms user's contact using the user contacts API.
+func (u *UserAPI) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error {
+ if err := u.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil {
+ return fmt.Errorf("failed to validate TOTP for contact: %w", err)
+ }
+
+ err := u.contactsAPI.ConfirmContact(ctx, contact.Paymail)
+ if err != nil {
+ return errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, "confirm contact", err).FormatPostErr()
+ }
+
+ return nil
+}
+
+// UnconfirmContact unconfirms a user contact with the given paymail via the user contacts API.
+// Returns an error if the API request fails or the response cannot be decoded. A nil error indicates the deleting confirmation was successful.
+func (u *UserAPI) UnconfirmContact(ctx context.Context, paymail string) error {
+ err := u.contactsAPI.UnconfirmContact(ctx, paymail)
+ if err != nil {
+ return errutil.NewHTTPErrorFormatter(constants.AdminContactsAPI, "unconfirm contact", err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// AcceptInvitation accepts a user contact with the given paymail via the user contacts API.
+// Returns an error if the API request fails or the response cannot be decoded. A nil error indicates the acceptation was successful.
+func (u *UserAPI) AcceptInvitation(ctx context.Context, paymail string) error {
+ err := u.invitationsAPI.AcceptInvitation(ctx, paymail)
+ if err != nil {
+ return errutil.NewHTTPErrorFormatter(constants.UserInvitationsAPI, "accept invitation", err).FormatPostErr()
+ }
+
+ return nil
+}
+
+// RejectInvitation rejects a user contact with the given paymail via the user contacts API.
+// Returns an error if the API request fails or the response cannot be decoded.
+// A nil error indicates the rejection was successful.
+func (u *UserAPI) RejectInvitation(ctx context.Context, paymail string) error {
+ err := u.invitationsAPI.RejectInvitation(ctx, paymail)
+ if err != nil {
+ return errutil.NewHTTPErrorFormatter(constants.UserInvitationsAPI, "reject invitation", err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// SharedConfig retrieves the shared configuration via the configurations API.
+// The response is unmarshaled into a response.SharedConfig.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) SharedConfig(ctx context.Context) (*response.SharedConfig, error) {
+ res, err := u.configsAPI.SharedConfig(ctx)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserSharedConfigAPI, "retrieve shared configuration", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// DraftTransaction creates a new draft transaction using the user transactions API.
+// The response is expected to be unmarshaled into a *response.DraftTransaction struct.
+// If the request fails or the response cannot be decoded, an error is returned.
+func (u *UserAPI) DraftTransaction(ctx context.Context, cmd *commands.DraftTransaction) (*response.DraftTransaction, error) {
+ res, err := u.transactionsAPI.DraftTransaction(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, "create a draft transaction", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// RecordTransaction submits a transaction for recording via the user transactions API.
+// The response is unmarshaled into a *response.Transaction.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) RecordTransaction(ctx context.Context, cmd *commands.RecordTransaction) (*response.Transaction, error) {
+ res, err := u.transactionsAPI.RecordTransaction(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("record a transaction with reference ID: %s", cmd.ReferenceID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, msg, err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// UpdateTransactionMetadata updates the metadata of a transaction via the user transactions API.
+// The response is expected to be unmarshaled into a *response.Transaction struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) UpdateTransactionMetadata(ctx context.Context, cmd *commands.UpdateTransactionMetadata) (*response.Transaction, error) {
+ res, err := u.transactionsAPI.UpdateTransactionMetadata(ctx, cmd)
+ if err != nil {
+ msg := fmt.Sprintf("record a transaction with ID: %s", cmd.ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, msg, err).FormatPutErr()
+ }
+
+ return res, nil
+}
+
+// Transactions retrieves a paginated list of transactions via the user transactions API.
+// The returned response includes transactions and pagination details, such as the page number,
+// sort order, and sorting field (sortBy).
+//
+// This method allows optional query parameters to be applied via the provided query options.
+// The response is expected to be to unmarshal into a *response.PageModel[response.Transaction] struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) Transactions(ctx context.Context, opts ...queries.QueryOption[filter.TransactionFilter]) (*queries.TransactionPage, error) {
+ res, err := u.transactionsAPI.Transactions(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, "retrieve transactions page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// Transaction retrieves a specific transaction by its ID via the user transactions API.
+// The response is expected to be unmarshaled into a *response.Transaction struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) Transaction(ctx context.Context, ID string) (*response.Transaction, error) {
+ res, err := u.transactionsAPI.Transaction(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("retrieve a transaction with ID: %s", ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, msg, err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// FinalizeTransaction finalizes a draft transaction and returns its signed hex representation.
+// It uses the draft transaction details to construct, enrich, and sign the transaction
+// through the `transactionsigner.TransactionSignedHex` utility function.
+// The response is the signed transaction in hex format.
+// Returns an error if the transaction cannot be finalized.
+func (u *UserAPI) FinalizeTransaction(draft *response.DraftTransaction) (string, error) {
+ res, err := u.transactionsAPI.FinalizeTransaction(draft)
+ if err != nil {
+ return "", fmt.Errorf("couldn't finalize transaction with ID: %s, %w", draft.ID, err)
+ }
+
+ return res, nil
+}
+
+// SendToRecipients creates, finalizes, and broadcasts a transaction to multiple recipients.
+// This method handles the complete process of drafting, finalizing, and recording the transaction
+// using the recipient details provided in the command.
+// The response is unmarshalled into a *response.Transaction struct.
+// Returns an error if the transaction fails at any step, such as drafting, finalization or recording.
+func (u *UserAPI) SendToRecipients(ctx context.Context, cmd *commands.SendToRecipients) (*response.Transaction, error) {
+ res, err := u.transactionsAPI.SendToRecipients(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserTransactionsAPI, "send to recipients", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// XPub retrieves the full xpub information for the current user via the users API.
+// The response is unmarshaled into a *response.Xpub.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) XPub(ctx context.Context) (*response.Xpub, error) {
+ res, err := u.xpubAPI.XPub(ctx)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserXPubsAPI, "retrieve xpub information", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// UpdateXPubMetadata updates the metadata associated with the current user's xpub via the users API.
+// The response is unmarshaled into a *response.Xpub.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) {
+ res, err := u.xpubAPI.UpdateXPubMetadata(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserXPubsAPI, "update xpub metadata ", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// GenerateAccessKey creates a new access key associated with the current user's xpub via the users access key API.
+// The response is unmarshaled into a *response.AccessKey.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) {
+ res, err := u.accessKeyAPI.GenerateAccessKey(ctx, cmd)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserAccessKeyAPI, "generate access key ", err).FormatPostErr()
+ }
+
+ return res, nil
+}
+
+// AccessKeys retrieves a paginated list of access keys via the user access keys API.
+// The response includes access keys and pagination details, such as the page number,
+// sort order, and sorting field (sortBy).
+//
+// This method allows optional query parameters to be applied via the provided query options.
+// The response is expected to unmarshal into a *queries.AccessKeyPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) AccessKeys(ctx context.Context, accessKeyOpts ...queries.QueryOption[filter.AccessKeyFilter]) (*queries.AccessKeyPage, error) {
+ res, err := u.accessKeyAPI.AccessKeys(ctx, accessKeyOpts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.AdminAccessKeyAPI, "retrieve access keys page ", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// AccessKey retrieves the access key associated with the specified ID via the user access keys API.
+// The response is expected to be unmarshaled into a *response.AccessKey struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) {
+ res, err := u.accessKeyAPI.AccessKey(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("retrieve access key with ID: %s", ID)
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserAccessKeyAPI, msg, err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// RevokeAccessKey revokes the access key associated with the given ID via the user access keys API.
+// If the request fails or the response cannot be processed, an error is returned.
+// A nil error indicates the revoking access key was successful.
+func (u *UserAPI) RevokeAccessKey(ctx context.Context, ID string) error {
+ err := u.accessKeyAPI.RevokeAccessKey(ctx, ID)
+ if err != nil {
+ msg := fmt.Sprintf("revoke access key with ID: %s", ID)
+ return errutil.NewHTTPErrorFormatter(constants.AdminAccessKeyAPI, msg, err).FormatDeleteErr()
+ }
+
+ return nil
+}
+
+// UTXOs fetches a paginated list of UTXOs via the user UTXOs API.
+// The response includes UTXOs along with pagination details, such as page number,
+// sort order, and sorting field.
+//
+// Optional query parameters can be applied using the provided query options.
+// The response is unmarshaled into a *queries.UtxosPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) UTXOs(ctx context.Context, opts ...queries.QueryOption[filter.UtxoFilter]) (*queries.UtxosPage, error) {
+ res, err := u.utxosAPI.UTXOs(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserUtxosAPI, "retrieve UTXOs page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// MerkleRoots retrieves a paginated list of Merkle roots via the user Merkle roots API.
+// The API response includes Merkle roots along with pagination details, such as the current
+// page number, sort order, and sorting field (sortBy).
+//
+// This method supports optional query parameters, which can be specified using the provided
+// query options. These options customize the behavior of the API request, such as setting
+// batch size or applying filters for pagination.
+//
+// The response is unmarshaled into a *queries.MerkleRootPage struct.
+// Returns an error if the request fails or the response cannot be decoded.
+func (u *UserAPI) MerkleRoots(ctx context.Context, opts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) {
+ res, err := u.merkleRootsAPI.MerkleRoots(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserMerkleRootAPI, "retrieve Merkle root page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// SyncMerkleRoots synchronizes Merkle roots known to the SPV Wallet with the client database.
+// This method sends a series of HTTP GET requests to the "/merkleroots" endpoint, fetching
+// Merkle roots and storing them in the client database. The process continues until all
+func (u *UserAPI) SyncMerkleRoots(ctx context.Context, repo merkleroots.MerkleRootsRepository) error {
+ err := u.merkleRootsAPI.SyncMerkleRoots(ctx, repo)
+ if err != nil {
+ return fmt.Errorf("failed to sync Merkle roots: %w", err)
+ }
+
+ return nil
+}
+
+// GenerateTotpForContact generates a TOTP code for the specified contact.
+func (u *UserAPI) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) {
+ if u.totpAPI == nil {
+ return "", errors.New("totp client not initialized - xPriv authentication required")
+ }
+
+ totp, err := u.totpAPI.GenerateTotpForContact(contact, period, digits)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate TOTP for contact: %w", err)
+ }
+
+ return totp, nil
+}
+
+// ValidateTotpForContact validates a TOTP code for the specified contact.
+func (u *UserAPI) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error {
+ if u.totpAPI == nil {
+ return errors.New("totp client not initialized - xPriv authentication required")
+ }
+
+ if err := u.totpAPI.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil {
+ return fmt.Errorf("failed to validate TOTP for contact: %w", err)
+ }
+
+ return nil
+}
+
+// Paymails retrieves a paginated list of paymail addresses via the User Paymails API.
+// The response includes user paymails along with pagination metadata, such as
+// the current page number, sort order, and the field used for sorting (sortBy).
+//
+// Query parameters can be configured using optional query options. These options allow
+// filtering based on metadata, pagination settings, or specific paymail attributes.
+//
+// The API response is unmarshaled into a *queries.PaymailAddressPage struct.
+// Returns an error if the API request fails or the response cannot be decoded.
+func (u *UserAPI) Paymails(ctx context.Context, opts ...queries.QueryOption[filter.PaymailFilter]) (*queries.PaymailsPage, error) {
+ res, err := u.paymailsAPI.Paymails(ctx, opts...)
+ if err != nil {
+ return nil, errutil.NewHTTPErrorFormatter(constants.UserPaymailAPI, "retrieve paymail addresses page", err).FormatGetErr()
+ }
+
+ return res, nil
+}
+
+// NewUserAPIWithXPub initializes a new UserAPI instance using an extended public key (xPub).
+// This function configures the API client with the provided configuration and uses the xPub key for authentication.
+// If any configuration or initialization step fails, an appropriate error is returned.
+//
+// Note: Requests made with this instance will not be signed.
+// For enhanced security, it is strongly recommended to use `NewUserAPIWithXPriv` or `NewUserAPIWithAccessKey` instead.
+func NewUserAPIWithXPub(cfg config.Config, xPub string) (*UserAPI, error) {
+ authenticator, err := auth.NewXpubOnlyAuthenticator(xPub)
+ if err != nil {
+ return nil, fmt.Errorf("failed to intialized xPub authenticator: %w", err)
+ }
+
+ return initUserAPI(cfg, authenticator)
+}
+
+// NewUserAPIWithXPriv initializes a new UserAPI instance using an extended private key (xPriv).
+// This function configures the API client with the provided configuration and uses the xPriv key for authentication.
+// If any step fails, an appropriate error is returned.
+//
+// Note: Requests made with this instance will be securely signed.
+func NewUserAPIWithXPriv(cfg config.Config, xPriv string) (*UserAPI, error) {
+ authenticator, err := auth.NewXprivAuthenticator(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to intialized xPriv authenticator: %w", err)
+ }
+
+ userAPI, err := initUserAPIWithXPriv(cfg, xPriv, authenticator)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create new client: %w", err)
+ }
+
+ return userAPI, nil
+}
+
+// NewUserAPIWithAccessKey initializes a new UserAPI instance using an access key.
+// This function configures the API client and converts the provided access key from either hex or WIF format into a private key.
+// This private key is used for authentication. If any step in the process fails, an appropriate error is returned.
+//
+// Note: Requests made with this instance will be securely signed.
+func NewUserAPIWithAccessKey(cfg config.Config, accessKey string) (*UserAPI, error) {
+ authenticator, err := auth.NewAccessKeyAuthenticator(accessKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err)
+ }
+
+ return initUserAPI(cfg, authenticator)
+}
+
+type authenticator interface {
+ Authenticate(r *resty.Request) error
+}
+
+func initUserAPIWithXPriv(cfg config.Config, xPriv string, auth authenticator) (*UserAPI, error) {
+ url, err := url.Parse(cfg.Addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err)
+ }
+
+ httpClient := restyutil.NewHTTPClient(cfg, auth)
+ transactionsAPI, err := transactions.NewAPIWithXPriv(url, httpClient, xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transactionsAPI: %w", err)
+ }
+
+ totpAPI, err := totp.NewAPI(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create totpAPI: %w", err)
+ }
+
+ return &UserAPI{
+ merkleRootsAPI: merkleroots.NewAPI(url, httpClient),
+ configsAPI: configs.NewAPI(url, httpClient),
+ transactionsAPI: transactionsAPI,
+ utxosAPI: utxos.NewAPI(url, httpClient),
+ accessKeyAPI: accesskeys.NewAPI(url, httpClient),
+ xpubAPI: xpubs.NewAPI(url, httpClient),
+ contactsAPI: contacts.NewAPI(url, httpClient),
+ invitationsAPI: invitations.NewAPI(url, httpClient),
+ paymailsAPI: paymails.NewAPI(url, httpClient),
+ totpAPI: totpAPI,
+ }, nil
+}
+
+func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) {
+ url, err := url.Parse(cfg.Addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err)
+ }
+
+ httpClient := restyutil.NewHTTPClient(cfg, auth)
+ if httpClient == nil {
+ return nil, fmt.Errorf("failed to initialize HTTP client - nil value")
+ }
+
+ transactionsAPI, err := transactions.NewAPI(url, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transactionsAPI: %w", err)
+ }
+
+ return &UserAPI{
+ merkleRootsAPI: merkleroots.NewAPI(url, httpClient),
+ configsAPI: configs.NewAPI(url, httpClient),
+ transactionsAPI: transactionsAPI,
+ utxosAPI: utxos.NewAPI(url, httpClient),
+ accessKeyAPI: accesskeys.NewAPI(url, httpClient),
+ xpubAPI: xpubs.NewAPI(url, httpClient),
+ contactsAPI: contacts.NewAPI(url, httpClient),
+ invitationsAPI: invitations.NewAPI(url, httpClient),
+ paymailsAPI: paymails.NewAPI(url, httpClient),
+ }, nil
+}
diff --git a/utils/utils.go b/utils/utils.go
deleted file mode 100644
index 84649d50..00000000
--- a/utils/utils.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// Package utils contains utility functions for the wallet like hashes and crypto functions
-package utils
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "math"
- "strconv"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
-)
-
-const (
- // XpubKeyLength is the length of an xPub string key
- XpubKeyLength = 111
-
- // ChainInternal internal chain num
- ChainInternal = uint32(1)
-
- // ChainExternal external chain num
- ChainExternal = uint32(0)
-
- // MaxInt32 max integer for int32
- MaxInt32 = int64(1<<(32-1) - 1)
-)
-
-// Hash returns the sha256 hash of the data string
-func Hash(data string) string {
- hash := sha256.Sum256([]byte(data))
- return hex.EncodeToString(hash[:])
-}
-
-// RandomHex returns a random hex string and error
-func RandomHex(n int) (string, error) {
- b := make([]byte, n)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- return hex.EncodeToString(b), nil
-}
-
-// DeriveChildKeyFromHex derive the child extended key from the hex string
-func DeriveChildKeyFromHex(hdKey *bip32.ExtendedKey, hexHash string) (*bip32.ExtendedKey, error) {
- var childKey *bip32.ExtendedKey
- childKey = hdKey
-
- childNums, err := GetChildNumsFromHex(hexHash)
- if err != nil {
- return nil, err
- }
-
- for _, num := range childNums {
- if childKey, err = childKey.Child(num); err != nil {
- return nil, err
- }
- }
-
- return childKey, nil
-}
-
-// GetChildNumsFromHex get an array of uint32 numbers from the hex string
-func GetChildNumsFromHex(hexHash string) ([]uint32, error) {
- strLen := len(hexHash)
- size := 8
- splitLength := int(math.Ceil(float64(strLen) / float64(size)))
- childNums := make([]uint32, 0)
- for i := 0; i < splitLength; i++ {
- start := i * size
- stop := start + size
- if stop > strLen {
- stop = strLen
- }
- num, err := strconv.ParseInt(hexHash[start:stop], 16, 64)
- if err != nil {
- return nil, err
- }
- if num > MaxInt32 {
- num = num - MaxInt32
- }
- childNums = append(childNums, uint32(num)) // todo: re-work to remove casting (possible cutoff)
- }
-
- return childNums, nil
-}
diff --git a/walletclient.go b/walletclient.go
deleted file mode 100644
index 412f7d59..00000000
--- a/walletclient.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package walletclient
-
-import (
- "net/http"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- ec "github.com/bitcoin-sv/go-sdk/primitives/ec"
-)
-
-// WalletClient is the spv wallet Go client representation.
-type WalletClient struct {
- signRequest bool
- server string
- httpClient *http.Client
- accessKey *ec.PrivateKey
- adminXPriv *bip32.ExtendedKey
- xPriv *bip32.ExtendedKey
- xPub *bip32.ExtendedKey
-}
-
-// NewWithXPriv creates a new WalletClient instance using a private key (xPriv).
-// It configures the client with a specific server URL and a flag indicating whether requests should be signed.
-// - `xPriv`: The extended private key used for cryptographic operations.
-// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003
-func NewWithXPriv(serverURL, xPriv string) (*WalletClient, error) {
- return makeClient(
- &xPrivConf{XPrivString: xPriv},
- &httpConf{ServerURL: serverURL},
- &signRequest{Sign: true},
- )
-}
-
-// NewWithXPub creates a new WalletClient instance using a public key (xPub).
-// This client is configured for operations that require a public key, such as verifying signatures or receiving transactions.
-// - `xPub`: The extended public key used for cryptographic verification and other public operations.
-// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003
-func NewWithXPub(serverURL, xPub string) (*WalletClient, error) {
- return makeClient(
- &xPubConf{XPubString: xPub},
- &httpConf{ServerURL: serverURL},
- &signRequest{Sign: false},
- )
-}
-
-// NewWithAdminKey creates a new WalletClient using an administrative key for advanced operations.
-// This configuration is typically used for administrative tasks such as managing sub-wallets or configuring system-wide settings.
-// - `adminKey`: The extended private key used for administrative operations.
-// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003
-func NewWithAdminKey(serverURL, adminKey string) (*WalletClient, error) {
- return makeClient(
- &adminKeyConf{AdminKeyString: adminKey},
- &httpConf{ServerURL: serverURL},
- &signRequest{Sign: true},
- )
-}
-
-// NewWithAccessKey creates a new WalletClient configured with an access key for API authentication.
-// This method is useful for scenarios where the client needs to authenticate using a less sensitive key than an xPriv.
-// - `accessKey`: The access key used for API authentication.
-// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003
-func NewWithAccessKey(serverURL, accessKey string) (*WalletClient, error) {
- return makeClient(
- &accessKeyConf{AccessKeyString: accessKey},
- &httpConf{ServerURL: serverURL},
- &signRequest{Sign: true},
- )
-}
-
-// makeClient creates a new WalletClient using the provided configuration options.
-func makeClient(configurators ...configurator) (*WalletClient, error) {
- client := &WalletClient{}
-
- var err error
- for _, configurator := range configurators {
- err = configurator.Configure(client)
- if err != nil {
- return nil, ErrCreateClient.Wrap(err)
- }
- }
-
- return client, nil
-}
-
-// addSignature will add the signature to the request
-func addSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error {
- return setSignature(header, xPriv, bodyString)
-}
-
-// SetAdminKeyByString will set aminXPriv key
-func (wc *WalletClient) SetAdminKeyByString(adminKey string) error {
- keyConf := accessKeyConf{AccessKeyString: adminKey}
- return keyConf.Configure(wc)
-}
diff --git a/walletclient_test.go b/walletclient_test.go
deleted file mode 100644
index 399a9ff0..00000000
--- a/walletclient_test.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package walletclient
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
- "github.com/stretchr/testify/require"
-)
-
-func TestNewWalletClient(t *testing.T) {
- // Create a mock HTTP server
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(`{"result": "success"}`))
- }))
- defer server.Close()
-
- serverURL := fmt.Sprintf("%s/v1", server.URL)
- // Test creating a client with a valid xPriv
- t.Run("NewWalletClientWithXPrivate success", func(t *testing.T) {
- keys, err := xpriv.Generate()
- require.NoError(t, err)
- client, err := NewWithXPriv(serverURL, keys.XPriv())
- require.NoError(t, err)
- require.NotNil(t, client.xPriv)
- require.Equal(t, keys.XPriv(), client.xPriv.String())
- require.NotNil(t, client.httpClient)
- require.True(t, client.signRequest)
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil)
- if err != nil {
- t.Fatalf("Failed to create HTTP request: %v", err)
- }
-
- // Ensure HTTP calls can be made
- resp, err := client.httpClient.Do(req)
- if err != nil {
- t.Fatalf("Failed to make HTTP request: %v", err)
- }
- defer resp.Body.Close()
-
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, resp.StatusCode)
- })
-
- t.Run("NewWalletClientWithXPrivate fail", func(t *testing.T) {
- xPriv := "invalid_key"
- client, err := NewWithXPriv(xPriv, "http://example.com")
- require.ErrorIs(t, err, ErrInvalidXpriv)
- require.Nil(t, client)
- })
-
- t.Run("NewWalletClientWithXPublic success", func(t *testing.T) {
- keys, err := xpriv.Generate()
- require.NoError(t, err)
- client, err := NewWithXPub(serverURL, keys.XPub().String())
- require.NoError(t, err)
- require.NotNil(t, client.xPub)
- require.Equal(t, keys.XPub().String(), client.xPub.String())
- require.NotNil(t, client.httpClient)
- require.False(t, client.signRequest)
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil)
- if err != nil {
- t.Fatalf("Failed to create HTTP request: %v", err)
- }
-
- // Ensure HTTP calls can be made
- resp, err := client.httpClient.Do(req)
- if err != nil {
- t.Fatalf("Failed to make HTTP request: %v", err)
- }
- defer resp.Body.Close()
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, resp.StatusCode)
- })
-
- t.Run("NewWalletClientWithXPublic fail", func(t *testing.T) {
- client, err := NewWithXPub(serverURL, "invalid_key")
- require.ErrorIs(t, err, ErrInvalidXpub)
- require.Nil(t, client)
- })
-
- t.Run("NewWalletClientWithAdminKey success", func(t *testing.T) {
- client, err := NewWithAdminKey(server.URL, fixtures.XPrivString)
- require.NoError(t, err)
- require.NotNil(t, client.adminXPriv)
- require.Nil(t, client.xPriv)
- require.Equal(t, fixtures.XPrivString, client.adminXPriv.String())
- require.Equal(t, serverURL, client.server)
- require.NotNil(t, client.httpClient)
- require.True(t, client.signRequest)
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil)
- if err != nil {
- t.Fatalf("Failed to create HTTP request: %v", err)
- }
-
- // Ensure HTTP calls can be made
- resp, err := client.httpClient.Do(req)
- if err != nil {
- t.Fatalf("Failed to make HTTP request: %v", err)
- }
- defer resp.Body.Close()
-
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, resp.StatusCode)
- })
-
- t.Run("NewWalletClientWithAdminKey fail", func(t *testing.T) {
- client, err := NewWithAdminKey(serverURL, "invalid_key")
- require.ErrorIs(t, err, ErrInvalidAdminKey)
- require.Nil(t, client)
- })
-
- t.Run("NewWalletClientWithAccessKey success", func(t *testing.T) {
- // Attempt to create a new WalletClient with an access key
- client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString)
- require.NoError(t, err)
- require.NotNil(t, client.accessKey)
-
- require.Equal(t, serverURL, client.server)
- require.True(t, client.signRequest)
- require.NotNil(t, client.httpClient)
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil)
- if err != nil {
- t.Fatalf("Failed to create HTTP request: %v", err)
- }
-
- // Ensure HTTP calls can be made
- resp, err := client.httpClient.Do(req)
- if err != nil {
- t.Fatalf("Failed to make HTTP request: %v", err)
- }
- defer resp.Body.Close()
-
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, resp.StatusCode)
- })
-
- t.Run("NewWalletClientWithAccessKey fail", func(t *testing.T) {
- client, err := NewWithAccessKey(serverURL, "invalid_key")
- require.ErrorIs(t, err, ErrInvalidAccessKey)
- require.Nil(t, client)
- })
-}
diff --git a/walletkeys/cmd/main.go b/walletkeys/cmd/main.go
new file mode 100644
index 00000000..634f038c
--- /dev/null
+++ b/walletkeys/cmd/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
+)
+
+func main() {
+ keys, err := walletkeys.RandomKeysWithMnemonic()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println("XPriv: ", keys.Keys.XPriv())
+ fmt.Println("XPub: ", keys.Keys.XPub())
+ fmt.Println("Mnemonic: ", keys.Mnemonic())
+}
diff --git a/walletkeys/walletkeys.go b/walletkeys/walletkeys.go
new file mode 100644
index 00000000..85f3d490
--- /dev/null
+++ b/walletkeys/walletkeys.go
@@ -0,0 +1,151 @@
+package walletkeys
+
+import (
+ "fmt"
+
+ bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
+ bip39 "github.com/bitcoin-sv/go-sdk/compat/bip39"
+ chaincfg "github.com/bitcoin-sv/go-sdk/transaction/chaincfg"
+)
+
+// DefaultEntropy defines the default entropy (bit size) used for cryptographic purposes.
+// The value must be a multiple of 32 and within the inclusive range of {128, 256}.
+// It represents the default level of entropy for key generation or similar operations.
+const DefaultEntropy = 128
+
+// Keys represents a set of hierarchical deterministic (HD) keys,
+// including the extended private key (XPriv) and extended public key (XPub).
+type Keys struct {
+ xPriv string
+ xPub string
+}
+
+// XPriv returns the HD extended private key as a string.
+func (k *Keys) XPriv() string { return k.xPriv }
+
+// XPub returns the HD extended public key as a string.
+func (k *Keys) XPub() string { return k.xPub }
+
+// KeysWithMnemonic extends the Keys struct by including the mnemonic phrase
+// used to generate the associated xPriv and XPub HD keys as strings.
+type KeysWithMnemonic struct {
+ Keys
+ mnemonic string
+}
+
+// Mnemonic returns the mnemonic phrase used to generate the keys.
+func (k *KeysWithMnemonic) Mnemonic() string { return k.mnemonic }
+
+// XPrivFromString generates an extended private key (xPriv) from a string.
+// It returns the nil extended private key and an error if the conversion fails.
+func XPrivFromString(s string) (*bip32.ExtendedKey, error) {
+ xPriv, err := bip32.NewKeyFromString(s)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate HD key from string: %w", err)
+ }
+
+ return xPriv, nil
+}
+
+// XPubFromXPriv derives an extended public key (xPub) from the provided xPriv string.
+// Returns an empty string and an error if the conversion fails.
+func XPubFromXPriv(s string) (string, error) {
+ xPriv, err := XPrivFromString(s)
+ if err != nil {
+ return "", fmt.Errorf("failed to get xPriv from string: %w", err)
+ }
+
+ key, err := xPriv.Neuter()
+ if err != nil {
+ return "", fmt.Errorf("failed to return the extedned public key: %w", err)
+ }
+
+ return key.String(), nil
+}
+
+// XPrivFromMnemonic generates an extended private key (xPriv) from a mnemonic phrase.
+// It returns the extended private key and an error if seed generation or HD key creation fails.
+func XPrivFromMnemonic(mnemonic string) (*bip32.ExtendedKey, error) {
+ seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate seed from mnemonic: %w", err)
+ }
+
+ xPriv, err := bip32.NewMaster(seed, &chaincfg.MainNet)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create master node HD key: %w", err)
+ }
+
+ return xPriv, nil
+}
+
+// RandomXPriv generates a random extended private key (xPriv).
+// The seed size is specified as 32 bytes (256 bits), as defined by the bip32.RecommendedSeedLen constant.
+// It returns a pointer to the extended private key and an error if seed generation or the creation of the master node HD key fails.
+func RandomXPriv() (*bip32.ExtendedKey, error) {
+ seed, err := bip32.GenerateSeed(bip32.RecommendedSeedLen)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate seed: %w", err)
+ }
+
+ xPriv, err := bip32.NewMaster(seed, &chaincfg.MainNet)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate master node HD key: %w", err)
+ }
+
+ return xPriv, nil
+}
+
+// RandomMnemonic generates a mnemonic phrase consisting of words derived from default entropy.
+// It returns the mnemonic as a string and an error if entropy generation or mnemonic creation fails.
+func RandomMnemonic() (string, error) {
+ entropy, err := bip39.NewEntropy(DefaultEntropy)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate entropy: %w", err)
+ }
+
+ mnemonic, err := bip39.NewMnemonic(entropy)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate mnemonic: %w", err)
+ }
+
+ return mnemonic, nil
+}
+
+// RandomKeys generates random HD keys (xPriv and xPub).
+// It returns a Keys struct containing the extended private and public keys and an error if any generation fails.
+func RandomKeys() (*Keys, error) {
+ xPriv, err := RandomXPriv()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate random xPriv: %w", err)
+ }
+
+ xPub, err := bip32.GetExtendedPublicKey(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get extended public key: %w", err)
+ }
+
+ return &Keys{xPriv: xPriv.String(), xPub: xPub}, nil
+}
+
+// RandomKeysWithMnemonic generates random HD keys (xPriv and xPub) along with a mnemonic phrase.
+// It returns a KeysWithMnemonic struct containing the keys and the associated mnemonic, and an error if any generation fails.
+func RandomKeysWithMnemonic() (*KeysWithMnemonic, error) {
+ mnemonic, err := RandomMnemonic()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate random mnemonic: %w", err)
+ }
+
+ xPriv, err := bip32.GenerateHDKeyFromMnemonic(mnemonic, "", &chaincfg.MainNet)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate HD key from mnemonic: %w", err)
+ }
+
+ xPub, err := bip32.GetExtendedPublicKey(xPriv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get extended public key: %w", err)
+ }
+
+ keys := Keys{xPriv: xPriv.String(), xPub: xPub}
+ return &KeysWithMnemonic{mnemonic: mnemonic, Keys: keys}, nil
+}
diff --git a/walletkeys/walletkeys_example_test.go b/walletkeys/walletkeys_example_test.go
new file mode 100644
index 00000000..fe48f933
--- /dev/null
+++ b/walletkeys/walletkeys_example_test.go
@@ -0,0 +1,45 @@
+package walletkeys_test
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys"
+)
+
+func ExampleRandomKeysWithMnemonic() {
+ keys, err := walletkeys.RandomKeysWithMnemonic()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println("Mnemonic: ", keys.Mnemonic())
+ fmt.Println("xPriv: ", keys.Keys.XPriv())
+ fmt.Println("XPub: ", keys.Keys.XPub())
+}
+
+func ExampleXPrivFromString() {
+ key := "xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si"
+ xPriv, err := walletkeys.XPrivFromString(key)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println("xPriv:", xPriv)
+
+ // Output:
+ // xPriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si
+}
+
+func ExampleXPrivFromMnemonic() {
+ mnemonic := "absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult"
+ xPriv, err := walletkeys.XPrivFromMnemonic(mnemonic)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println("xPriv:", xPriv)
+
+ // Output:
+ // xPriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si
+}
diff --git a/xpriv/example_test.go b/xpriv/example_test.go
deleted file mode 100644
index ad4143d0..00000000
--- a/xpriv/example_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package xpriv_test
-
-import (
- "fmt"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
-)
-
-func ExampleGenerate() {
- keys, _ := xpriv.Generate()
-
- fmt.Println("xpriv:", keys.XPriv())
- fmt.Println("xpub:", keys.XPub().String())
-}
-
-func ExampleFromMnemonic() {
- keys, _ := xpriv.FromMnemonic("absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult")
-
- fmt.Println("mnemonic:", keys.Mnemonic())
- fmt.Println("xpriv:", keys.XPriv())
- fmt.Println("xpub:", keys.XPub().String())
-
- // Output:
- // mnemonic: absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult
- // xpriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si
- // xpub: xpub661MyMwAqRbcFpmY3fFdD4V6ueUBTcaCi49XDCPbRTs5XtDomZpzxAS3LUb2hMfUVphDsSPxfjietmsBRFkLDY9Xa3P4jbgNDMnDK3UqJe2
-}
diff --git a/xpriv/xpriv.go b/xpriv/xpriv.go
deleted file mode 100644
index f87ca3d3..00000000
--- a/xpriv/xpriv.go
+++ /dev/null
@@ -1,146 +0,0 @@
-// Package xpriv manges keys
-package xpriv
-
-// "github.com/libsv/go-bk/bip39" - no replacements
-
-import (
- "fmt"
-
- bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32"
- bip39 "github.com/bitcoin-sv/go-sdk/compat/bip39"
- chaincfg "github.com/bitcoin-sv/go-sdk/transaction/chaincfg"
-)
-
-// Keys is a struct containing the xpriv, xpub and mnemonic
-type Keys struct {
- xpriv string
- xpub PublicKey
- mnemonic string
-}
-
-// PublicKey is a struct containing public key information
-type PublicKey string
-
-// Key represents basic key methods
-type Key interface {
- XPriv() string
- XPub() PubKey
-}
-
-// PubKey represents public key methods
-type PubKey interface {
- String() string
-}
-
-// KeyWithMnemonic represents methods for generated keys
-type KeyWithMnemonic interface {
- Key
- Mnemonic() string
-}
-
-// XPub return hierarchical struct which contain xpub info
-func (k *Keys) XPub() PubKey {
- return k.xpub
-}
-
-// XPriv return hierarchical deterministic private key
-func (k *Keys) XPriv() string {
- return k.xpriv
-}
-
-// Mnemonic return mnemonic from which keys where generated
-func (k *Keys) Mnemonic() string {
- return k.mnemonic
-}
-
-// String return hierarchical deterministic publick ey
-func (k PublicKey) String() string {
- return string(k)
-}
-
-// Generate generates a random set of keys - xpriv, xpb and mnemonic
-func Generate() (KeyWithMnemonic, error) {
- entropy, err := bip39.NewEntropy(160)
- if err != nil {
- return nil, fmt.Errorf("generate method: key generation error when creating entropy: %w", err)
- }
-
- mnemonic, err := bip39.NewMnemonic(entropy)
-
- if err != nil {
- return nil, fmt.Errorf("generate method: key generation error when creating mnemonic: %w", err)
- }
-
- hdKey, err := bip32.GenerateHDKeyFromMnemonic(mnemonic, "", &chaincfg.MainNet)
- if err != nil {
- return nil, err
- }
-
- hdXpriv := hdKey.String()
- hdXpub, err := bip32.GetExtendedPublicKey(hdKey)
- if err != nil {
- return nil, err
- }
-
- keys := &Keys{
- xpriv: hdXpriv,
- xpub: PublicKey(hdXpub),
- mnemonic: mnemonic,
- }
-
- return keys, nil
-}
-
-// FromMnemonic generates Keys based on given mnemonic
-func FromMnemonic(mnemonic string) (KeyWithMnemonic, error) {
- seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
- if err != nil {
- return nil, fmt.Errorf("FromMnemonic method: error when creating seed: %w", err)
- }
-
- hdXpriv, hdXpub, err := createXPrivAndXPub(seed)
- if err != nil {
- return nil, fmt.Errorf("FromMnemonic method: %w", err)
- }
-
- keys := &Keys{
- xpriv: hdXpriv.String(),
- xpub: PublicKey(hdXpub.String()),
- mnemonic: mnemonic,
- }
-
- return keys, nil
-}
-
-// FromString generates keys from given xpriv
-func FromString(xpriv string) (Key, error) {
- hdXpriv, err := bip32.NewKeyFromString(xpriv)
- if err != nil {
- return nil, fmt.Errorf("FromString method: key generation error when creating hd private key: %w", err)
- }
-
- hdXpub, err := hdXpriv.Neuter()
- if err != nil {
- return nil, fmt.Errorf("FromString method: key generation error when creating hd public hey: %w", err)
- }
-
- keys := &Keys{
- xpriv: hdXpriv.String(),
- xpub: PublicKey(hdXpub.String()),
- }
-
- return keys, nil
-}
-
-func createXPrivAndXPub(seed []byte) (hdXpriv *bip32.ExtendedKey, hdXpub *bip32.ExtendedKey, err error) {
- hdXpriv, err = bip32.NewMaster(seed, &chaincfg.MainNet)
- if err != nil {
- return nil, nil, fmt.Errorf("key generation error when creating hd private key: %w", err)
- }
-
- hdXpub, err = hdXpriv.Neuter()
- if err != nil {
- return nil, nil, fmt.Errorf("key generation error when creating hd public hey: %w", err)
- }
- return hdXpriv, hdXpub, nil
-}
diff --git a/xpubs_test.go b/xpubs_test.go
deleted file mode 100644
index dfddea92..00000000
--- a/xpubs_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package walletclient
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
- "github.com/bitcoin-sv/spv-wallet/models"
- "github.com/stretchr/testify/require"
-)
-
-type xpub struct {
- CurrentBalance uint64 `json:"current_balance"`
- Metadata *models.Metadata `json:"metadata"`
-}
-
-func TestXpub(t *testing.T) {
- var update bool
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- var response xpub
- // Check path and method to customize the response
- switch {
- case r.URL.Path == "/v1/xpub":
- metadata := &models.Metadata{"key": "value"}
- if update {
- metadata = &models.Metadata{"updated": "info"}
- }
- response = xpub{
- CurrentBalance: 1234,
- Metadata: metadata,
- }
- }
- respBytes, _ := json.Marshal(response)
- w.Write(respBytes)
- }))
- defer server.Close()
- keys, err := xpriv.Generate()
- require.NoError(t, err)
-
- client, err := NewWithXPriv(server.URL, keys.XPriv())
- require.NoError(t, err)
- require.NotNil(t, client.xPriv)
-
- t.Run("GetXPub", func(t *testing.T) {
- xpub, err := client.GetXPub(context.Background())
- require.NoError(t, err)
- require.NotNil(t, xpub)
- require.Equal(t, uint64(1234), xpub.CurrentBalance)
- require.Equal(t, "value", xpub.Metadata["key"])
- })
-
- t.Run("UpdateXPubMetadata", func(t *testing.T) {
- update = true
- metadata := map[string]any{"updated": "info"}
- xpub, err := client.UpdateXPubMetadata(context.Background(), metadata)
- require.NoError(t, err)
- require.NotNil(t, xpub)
- require.Equal(t, "info", xpub.Metadata["updated"])
- })
-}