diff --git a/.github/mergify.yml b/.github/mergify.yml deleted file mode 100644 index c7b457b..0000000 --- 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 0f344d7..ef94211 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 0000000..1bf88e9 --- /dev/null +++ b/.golangci-lint.yml @@ -0,0 +1,196 @@ +# 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 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 0000000..3fa083c --- /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 006dcd8..0000000 --- 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/access_keys_test.go b/access_keys_test.go deleted file mode 100644 index 42732a8..0000000 --- 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 0000000..d998a81 --- /dev/null +++ b/admin_api.go @@ -0,0 +1,110 @@ +package spvwallet + +import ( + "context" + "fmt" + "net/url" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/config" + xpubs "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/users" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "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/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 { + xpubsAPI *xpubs.API // Internal API for managing operations related to XPubs. +} + +// 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, xpubs.HTTPErrorFormatter("failed to 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.XPubQueryOption) (*queries.XPubPage, error) { + res, err := a.xpubsAPI.XPubs(ctx, opts...) + if err != nil { + return nil, xpubs.HTTPErrorFormatter("failed to retrieve XPubs page", err).FormatGetErr() + } + + return res, 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) { + key, err := bip32.GenerateHDKeyFromString(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPriv: %w", err) + } + + authenticator, err := auth.NewXprivAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to initialize xPriv authenticator: %w", err) + } + + return initAdminAPI(cfg, authenticator) +} + +// NewAdminWithXPub 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 NewAdminWithXPub(cfg config.Config, xPub string) (*AdminAPI, error) { + key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) + } + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + 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) + return &AdminAPI{xpubsAPI: xpubs.NewAPI(url, httpClient)}, nil +} diff --git a/admin_contacts_test.go b/admin_contacts_test.go deleted file mode 100644 index ea0381a..0000000 --- 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/client_options.go b/client_options.go deleted file mode 100644 index 9e2df8b..0000000 --- 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 5627afa..0000000 --- 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 2174583..56c50c4 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 0000000..76d7edf --- /dev/null +++ b/commands/contacts.go @@ -0,0 +1,8 @@ +package commands + +// UpsertContact holds the necessary arguments for adding or updating a user's contact information. +type UpsertContact struct { + FullName string `json:"fullName"` // The full name of the user. + Metadata map[string]any `json:"metadata"` // Metadata associated with the transaction. + Paymail string `json:"requesterPaymail"` // Paymail address of the user, which is used for secure and simplified payment transfers. +} diff --git a/commands/transactions.go b/commands/transactions.go new file mode 100644 index 0000000..c6ca674 --- /dev/null +++ b/commands/transactions.go @@ -0,0 +1,43 @@ +package commands + +import ( + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// RecordTransaction holds the arguments required to record a user transaction. +type RecordTransaction struct { + Metadata querybuilders.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 querybuilders.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 querybuilders.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 querybuilders.Metadata `json:"metadata"` // Metadata associated with the transaction. +} diff --git a/commands/users.go b/commands/users.go new file mode 100644 index 0000000..d689003 --- /dev/null +++ b/commands/users.go @@ -0,0 +1,13 @@ +package commands + +// UpdateXPubMetadata contains the parameters needed to update the metadata +// associated with the current user's xpub. +type UpdateXPubMetadata struct { + Metadata map[string]any `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 map[string]any `json:"metadata"` // Key-value pairs representing the access key metadata +} diff --git a/commands/xpub.go b/commands/xpub.go new file mode 100644 index 0000000..1a42a22 --- /dev/null +++ b/commands/xpub.go @@ -0,0 +1,9 @@ +package commands + +import "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + +// CreateUserXpub contains the parameters required to register a user's XPub. +type CreateUserXpub struct { + Metadata querybuilders.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 cde6aeb..0000000 --- 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 0000000..c3a7f7b --- /dev/null +++ b/config/config.go @@ -0,0 +1,25 @@ +package config + +import ( + "net/http" + "time" +) + +// 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. +} + +// NewDefaultConfig returns a default configuration for connecting to the SPV Wallet API, +// setting a one-minute timeout, using the default HTTP transport, and applying the +// base API address as the addr value. +func NewDefaultConfig(addr string) Config { + return Config{ + Addr: addr, + Timeout: 1 * time.Minute, + Transport: http.DefaultTransport, + } +} diff --git a/contacts_test.go b/contacts_test.go deleted file mode 100644 index 5781eda..0000000 --- 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 f117bf2..0000000 --- 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 277ff34..0000000 --- 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 0000000..f9d6cc4 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,27 @@ +package errors + +import ( + "errors" +) + +var ( + // 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") + // ErrFilterQueryBuilder is returned when the filter query builder fails to build the operation. + ErrFilterQueryBuilder = errors.New("filter query builder - build operation failure") + // 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") +) diff --git a/examples/README.md b/examples/README.md index 73b27ad..d928290 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,46 +1,139 @@ -# Quick Guide how to run examples +# Qucik 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](#authroization) +1. [How to run example](#how-to-run-an-example) ## 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). +- [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. -### Concerning the keys +``` +task: [default] task --list +task: Available tasks for this project: +* default: Display all available tasks. +* fetch-user-contact-by-paymail: Fetch user contact by given paymail. +* fetch-user-contacts: Fetch user contacts page. +* fetch-user-merkleroots: Fetch user Merkle roots page. +* fetch-user-shared-config: Fetch user shared configuration. +* fetch-user-transaction: Fetch user transaction with a given ID. +* fetch-user-transactions: Fetch user transactions page. +* fetch-user-utxos: Fetch user UTXOs page. +* fetch-user-xpub: Fetch current authorized user's xpub info. +* generate-keys: Generate keys for SPV Wallet API access. +* user-contact-confirmation: Confirm user contact with a given paymail address. +* user-contact-remove: Remove user contact with a given paymail address. +* user-contact-unconfirm: Unconfirm user contact with a given paymail address. +* user-contact-upsert: Upsert user contact with a given paymail address. +* user-draft-transaction: Create a user draft transaction. +* user-invitation-accept: Accept user contact invitation with a given paymail address. +* user-invitation-reject: Reject user contact invitation with a given paymail address. +* user-transaction-metadata-update: Update user transaction metadata with a given ID. +* user-xpub-metadata: Update current authorized user's xpub metadata. +``` -- 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). +## Authroization +> [!CAUTION] +> Don't use the keys which are already added to another wallet. + + +> [!IMPORTANT] > Additionally, to make it work properly, you should adjust the `ExamplePaymail` to align with your `domains` configuration in the `spv-wallet` instance. -## Proposed order of executing examples +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. -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) +**Example output:** +``` +================================================================== +XPriv: xprv1d77e47e-452c-453f-bc4c-a42748f8145f +XPub: xpubd82c277b-0a7e-482f-8ad8-e92958d15acb +Mnemonic: mnemonic +================================================================== +``` -> 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. +## -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 +> [!TIP] +> Previously generated keys can be used as function parameters. -In addition to the above, there are additional examples showing how to use the client from a developer perspective: +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. -- `handle_exceptions` - presents how to "catch" exceptions which the client can throw +**Code snippet:** -## Util examples +``` +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" +) + +func main() { + xPriv := "121d2f43-4261-42ab-813e-3d3fa4d87313" + cfg := wallet.NewDefaultConfig("http://localhost:3003") + spv, err := wallet.NewWithXPriv(cfg, xPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := spv.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)) +} -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 +``` +**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 +} +``` ## How to run an example @@ -51,4 +144,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 88689de..7ca3a84 100644 --- a/examples/Taskfile.yml +++ b/examples/Taskfile.yml @@ -1,78 +1,123 @@ ---- version: "3" tasks: - admin_add_user: - desc: "running admin_add_user..." + default: cmds: - - echo "running admin_add_user..." - - go run ./admin_add_user/admin_add_user.go + - task --list + desc: "Display all available tasks." - admin_remove_user: - desc: "running admin_remove_user..." + generate-keys: + desc: "Generate keys for SPV Wallet API access." + silent: true cmds: - - echo "running admin_remove_user..." - - go run ./admin_remove_user/admin_remove_user.go + - echo "==================================================================" + - go run ../walletkeys/cmd/main.go + - echo "==================================================================" - create_transaction: - desc: "running create_transaction..." + fetch-user-shared-config: + desc: "Fetch user shared configuration." + silent: true cmds: - - echo "running create_transaction..." - - go run ./create_transaction/create_transaction.go + - go run ./fetch_shared_config/fetch_shared_config.go - generate_keys: - desc: "running generate_keys..." + fetch-user-merkleroots: + desc: "Fetch user Merkle roots page." + silent: true cmds: - - echo "running generate_keys..." - - go run ./generate_keys/generate_keys.go + - go run ./fetch_merkleroots/fetch_merkleroots.go - get_balance: - desc: "running get_balance..." + fetch-user-contacts: + desc: "Fetch user contacts page." + silent: true cmds: - - echo "running get_balance..." - - go run ./get_balance/get_balance.go + - go run ./fetch_contacts/fetch_contacts.go - handle_exceptions: - desc: "running handle_exceptions..." + fetch-user-contact-by-paymail: + desc: "Fetch user contact by given paymail." + silent: true cmds: - - echo "running handle_exceptions..." - - go run ./handle_exceptions/handle_exceptions.go + - go run ./fetch_contact_by_paymail/fetch_contact_by_paymail.go - list_transactions: - desc: "running list_transactions..." + user-contact-confirmation: + desc: "Confirm user contact with a given paymail address." + silent: true cmds: - - echo "running list_transactions..." - - go run ./list_transactions/list_transactions.go + - go run ./contact_confirmation/contact_confirmation.go - send_op_return: - desc: "running send_op_return..." + user-contact-unconfirm: + desc: "Unconfirm user contact with a given paymail address." + silent: true cmds: - - echo "running send_op_return..." - - go run ./send_op_return/send_op_return.go + - go run ./unconfirm_contact/unconfirm_contact.go + + user-contact-remove: + desc: "Remove user contact with a given paymail address." + silent: true + cmds: + - go run ./contact_remove/contact_remove.go + + user-contact-upsert: + desc: "Upsert user contact with a given paymail address." + silent: true + cmds: + - go run ./contact_upsert/contact_upsert.go - xpriv_from_mnemonic: - desc: "running xpriv_from_mnemonic..." + user-invitation-accept: + desc: "Accept user contact invitation with a given paymail address." + silent: true cmds: - - echo "running xpriv_from_mnemonic..." - - go run ./xpriv_from_mnemonic/xpriv_from_mnemonic.go + - go run ./accept_invitation/accept_invitation.go - xpub_from_xpriv: - desc: "running xpub_from_xpriv..." + user-invitation-reject: + desc: "Reject user contact invitation with a given paymail address." + silent: true cmds: - - echo "running xpub_from_xpriv..." - - go run ./xpub_from_xpriv/xpub_from_xpriv.go - generate_totp: - desc: "running generate_totp..." + - go run ./reject_invitation/reject_invitation.go + + fetch-user-transactions: + desc: "Fetch user transactions page." + silent: true cmds: - - echo "running generate_totp..." - - go run ./generate_totp/generate_totp.go - webhooks: - desc: "running webhooks..." + - go run ./fetch_transactions/fetch_transactions.go + + fetch-user-transaction: + desc: "Fetch user transaction with a given ID." + silent: true cmds: - - echo "running webhooks..." - - go run ./webhooks/webhooks.go || true - sync_merkleroots: - desc: "running sync_merkleroots.." + - go run ./fetch_transaction/fetch_transaction.go + + user-draft-transaction: + desc: "Create a user draft transaction." + silent: true + cmds: + - go run ./draft_transaction/draft_transaction.go + + user-transaction-metadata-update: + desc: "Update user transaction metadata with a given ID." + silent: true + cmds: + - go run ./update_transaction_metadata/update_transaction_metadata.go + + create-transaction: + desc: "Send OP return." + silent: true + cmds: + - go run ./send_op_return/send_op_return.go + + fetch-user-utxos: + desc: "Fetch user UTXOs page." + silent: true + cmds: + - go run ./fetch_utxos/fetch_utxos.go + + fetch-user-xpub: + desc: "Fetch current authorized user's xpub info." + silent: true + cmds: + - go run ./fetch_xpub/fetch_xpub.go + + user-xpub-metadata: + desc: "Update current authorized user's xpub metadata." + silent: true cmds: - - echo "running sync_merkleroots..." - - go run ./sync_merkleroots/sync_merkleroots.go + - go run ./update_xpub_metadata/update_xpub_metadata.go diff --git a/examples/accept_invitation/accept_invitation.go b/examples/accept_invitation/accept_invitation.go new file mode 100644 index 0000000..5016e73 --- /dev/null +++ b/examples/accept_invitation/accept_invitation.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.AcceptInvitation(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP POST] Accept contact invitation - api/v1/invitations/%s/contacts", paymail)) +} diff --git a/examples/admin_add_user/admin_add_user.go b/examples/admin_add_user/admin_add_user.go deleted file mode 100644 index d064513..0000000 --- a/examples/admin_add_user/admin_add_user.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Package main - admin_add_user 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() - - examples.CheckIfAdminKeyExists() - - server := "http://localhost:3003/v1" - - adminClient, err := walletclient.NewWithAdminKey(server, examples.ExampleAdminKey) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - metadata := map[string]any{"some_metadata": "example"} - - err = adminClient.AdminNewXpub(ctx, examples.ExampleXPub, metadata) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - createPaymailRes, err := adminClient.AdminCreatePaymail(ctx, examples.ExampleXPub, examples.ExamplePaymail, "Some public name", "") - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("AdminCreatePaymail response: ", createPaymailRes) -} 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 95b7949..0000000 --- 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/contact_confirmation/contact_confirmation.go b/examples/contact_confirmation/contact_confirmation.go new file mode 100644 index 0000000..05120ce --- /dev/null +++ b/examples/contact_confirmation/contact_confirmation.go @@ -0,0 +1,28 @@ +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" + "github.com/bitcoin-sv/spv-wallet/models" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + code := "f22b4214-ab56-45c0-8399-60ed3a4ecf8e" + err = usersAPI.ConfirmContact(context.Background(), &models.Contact{ID: "b2215c13-5690-469e-868f-e7bc240a0a23"}, code, paymail, 1, 8) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP POST] Confirm contact - api/v1/contacts/%s/confirmation", paymail)) +} diff --git a/examples/contact_remove/contact_remove.go b/examples/contact_remove/contact_remove.go new file mode 100644 index 0000000..b76d791 --- /dev/null +++ b/examples/contact_remove/contact_remove.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.RemoveContact(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Remove contact - api/v1/contacts/%s", paymail)) +} diff --git a/examples/contact_upsert/contact_upsert.go b/examples/contact_upsert/contact_upsert.go new file mode 100644 index 0000000..fe0657b --- /dev/null +++ b/examples/contact_upsert/contact_upsert.go @@ -0,0 +1,33 @@ +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" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + contact, err := usersAPI.UpsertContact(context.Background(), commands.UpsertContact{ + FullName: "John Doe", + Metadata: map[string]any{ + "key": "value", + }, + Paymail: paymail, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP PUT] Upsert contact - api/v1/contacts/%s", paymail), contact) +} diff --git a/examples/create_transaction/create_transaction.go b/examples/create_transaction/create_transaction.go deleted file mode 100644 index d3d6a64..0000000 --- a/examples/create_transaction/create_transaction.go +++ /dev/null @@ -1,46 +0,0 @@ -/* -Package main - create_transaction 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() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - 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) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("SendToRecipients response: ", newTransaction) - - tx, err := client.GetTransaction(ctx, newTransaction.ID) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("GetTransaction response: ", tx) -} diff --git a/examples/errors.go b/examples/errors.go deleted file mode 100644 index 932a752..0000000 --- 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 8c52747..7ff7492 100644 --- a/examples/example_keys.go +++ b/examples/example_keys.go @@ -1,19 +1,7 @@ -/* -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 = "" - - // ExamplePaymail - example Paymail address - ExamplePaymail string = "" + XPriv string = "" + XPub string = "" + AccessKey string = "" ) diff --git a/examples/exampleutil/exampleutil.go b/examples/exampleutil/exampleutil.go new file mode 100644 index 0000000..a9b9a7e --- /dev/null +++ b/examples/exampleutil/exampleutil.go @@ -0,0 +1,23 @@ +package exampleutil + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/bitcoin-sv/spv-wallet-go-client/config" +) + +var ExampleConfig = config.NewDefaultConfig("http://localhost:3003") + +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)) +} diff --git a/examples/fetch_access_key/fetch_access_key.go b/examples/fetch_access_key/fetch_access_key.go new file mode 100644 index 0000000..fe346e4 --- /dev/null +++ b/examples/fetch_access_key/fetch_access_key.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeyID := "35465782-e247-42dd-a2e7-a01ba5b56285" + accessKey, err := usersAPI.AccessKey(context.Background(), accessKeyID) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Access key - api/v1/users/current/keys/%s", accessKeyID), accessKey) +} diff --git a/examples/fetch_access_keys/fetch_access_keys.go b/examples/fetch_access_keys/fetch_access_keys.go new file mode 100644 index 0000000..aab1a50 --- /dev/null +++ b/examples/fetch_access_keys/fetch_access_keys.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeys, err := usersAPI.AccessKeys(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Access keys - api/v1/users/current/keys", accessKeys) +} diff --git a/examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go b/examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go new file mode 100644 index 0000000..d821f49 --- /dev/null +++ b/examples/fetch_contact_by_paymail/fetch_contact_by_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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + contact, err := usersAPI.ContactWithPaymail(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Contact by paymail - api/v1/contacts/%s", paymail), contact) +} diff --git a/examples/fetch_contacts/fetch_contacts.go b/examples/fetch_contacts/fetch_contacts.go new file mode 100644 index 0000000..44e46b9 --- /dev/null +++ b/examples/fetch_contacts/fetch_contacts.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.Contacts(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Contacts page - api/v1/contacts", page) +} diff --git a/examples/fetch_merkleroots/fetch_merkleroots.go b/examples/fetch_merkleroots/fetch_merkleroots.go new file mode 100644 index 0000000..399bf64 --- /dev/null +++ b/examples/fetch_merkleroots/fetch_merkleroots.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.MerkleRoots(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Merkle roots page - api/v1/merkleroots", page) +} diff --git a/examples/fetch_shared_config/fetch_shared_config.go b/examples/fetch_shared_config/fetch_shared_config.go new file mode 100644 index 0000000..754b7dc --- /dev/null +++ b/examples/fetch_shared_config/fetch_shared_config.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + res, err := usersAPI.SharedConfig(context.Background()) + if err != nil { + log.Fatal() + } + + exampleutil.Print("[HTTP GET] Shared config - api/v1/configs/shared", res) +} diff --git a/examples/fetch_transaction/fetch_transaction.go b/examples/fetch_transaction/fetch_transaction.go new file mode 100644 index 0000000..2a5d7dc --- /dev/null +++ b/examples/fetch_transaction/fetch_transaction.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transactionID := "d291ac4c-04b3-48cc-a7e3-1338ac42810b" + transaction, err := usersAPI.Transaction(context.Background(), transactionID) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Transaction - api/v1/transactions/%s", transactionID), transaction) +} diff --git a/examples/fetch_transactions/fetch_transactions.go b/examples/fetch_transactions/fetch_transactions.go new file mode 100644 index 0000000..770782f --- /dev/null +++ b/examples/fetch_transactions/fetch_transactions.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.Transactions(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Transaction page - api/v1/transactions", page) +} diff --git a/examples/fetch_utxos/fetch_utxos.go b/examples/fetch_utxos/fetch_utxos.go new file mode 100644 index 0000000..9f2e455 --- /dev/null +++ b/examples/fetch_utxos/fetch_utxos.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.UTXOs(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] UTXOs page - api/v1/utxos", page) +} diff --git a/examples/fetch_xpub/fetch_xpub.go b/examples/fetch_xpub/fetch_xpub.go new file mode 100644 index 0000000..02db365 --- /dev/null +++ b/examples/fetch_xpub/fetch_xpub.go @@ -0,0 +1,24 @@ +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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := usersAPI.XPub(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Current user xPub - api/v1/users/current", xPub) +} diff --git a/examples/generate_access_key/generate_access_key.go b/examples/generate_access_key/generate_access_key.go new file mode 100644 index 0000000..749089b --- /dev/null +++ b/examples/generate_access_key/generate_access_key.go @@ -0,0 +1,28 @@ +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" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + accessKey, err := usersAPI.GenerateAccessKey(ctx, &commands.GenerateAccessKey{ + Metadata: map[string]any{"key": "value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP POST] Generate access key - api/v1/users/current/keys", accessKey) +} diff --git a/examples/generate_keys/generate_keys.go b/examples/generate_keys/generate_keys.go deleted file mode 100644 index 93af46c..0000000 --- a/examples/generate_keys/generate_keys.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - generate_keys example -*/ -package main - -import ( - "fmt" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func main() { - keys, err := xpriv.Generate() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - exampleXPriv := keys.XPriv() - exampleXPub := keys.XPub().String() - - fmt.Println("exampleXPriv: ", exampleXPriv) - fmt.Println("exampleXPub: ", exampleXPub) -} diff --git a/examples/generate_totp/generate_totp.go b/examples/generate_totp/generate_totp.go deleted file mode 100644 index 7a06fb8..0000000 --- a/examples/generate_totp/generate_totp.go +++ /dev/null @@ -1,48 +0,0 @@ -/* -Package main - generate_totp example -*/ -package main - -import ( - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet/models" -) - -func main() { - defer examples.HandlePanic() - - const server = "http://localhost:3003/v1" - const aliceXPriv = "xprv9s21ZrQH143K4JFXqGhBzdrthyNFNuHPaMUwvuo8xvpHwWXprNK7T4JPj1w53S1gojQncyj8JhSh8qouYPZpbocsq934cH5G1t1DRBfgbod" - const bobPKI = "03a48e13dc598dce5fda9b14ea13f32d5dbc4e8d8a34447dda84f9f4c457d57fe7" - const digits = 4 - const period = 1200 // 20 minutes - - client, err := walletclient.NewWithXPriv(server, aliceXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - mockContact := &models.Contact{ - PubKey: bobPKI, - Paymail: "test@paymail.com", - } - - totpCode, err := client.GenerateTotpForContact(mockContact, period, digits) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("TOTP code from Alice to Bob: ", totpCode) - - valid, err := client.ValidateTotpForContact(mockContact, totpCode, mockContact.Paymail, period, digits) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Is TOTP code valid: ", valid) -} diff --git a/examples/get_balance/get_balance.go b/examples/get_balance/get_balance.go deleted file mode 100644 index 2d0ee6a..0000000 --- a/examples/get_balance/get_balance.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -Package main - get_balance 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() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - xpubInfo, err := client.GetXPub(ctx) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Current balance: ", xpubInfo.CurrentBalance) -} diff --git a/examples/go.mod b/examples/go.mod deleted file mode 100644 index 3f670b6..0000000 --- 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.31 -) - -require ( - github.com/bitcoin-sv/go-sdk v1.1.9 // 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.26.0 // indirect -) diff --git a/examples/go.sum b/examples/go.sum deleted file mode 100644 index 53a449e..0000000 --- a/examples/go.sum +++ /dev/null @@ -1,24 +0,0 @@ -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.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= -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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.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= -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 ed55de4..0000000 --- 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_transactions/list_transactions.go b/examples/list_transactions/list_transactions.go deleted file mode 100644 index 670c2f2..0000000 --- a/examples/list_transactions/list_transactions.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Package main - list_transactions 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" - "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) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - 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) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Filtered GetTransactions response: ", txsFiltered) -} diff --git a/examples/record_transaction/record_transaction.go b/examples/record_transaction/record_transaction.go new file mode 100644 index 0000000..3c3fd37 --- /dev/null +++ b/examples/record_transaction/record_transaction.go @@ -0,0 +1,29 @@ +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" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transaction, err := usersAPI.RecordTransaction(context.Background(), &commands.RecordTransaction{ + Metadata: map[string]any{"key": "value"}, + ReferenceID: "8bc53e34-b6fd-4e8b-b1b7-6f30f8f149f2", + Hex: "0100000002...", + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP POST] Record transaction - api/v1/transactions", transaction) +} diff --git a/examples/reject_invitation/reject_invitation.go b/examples/reject_invitation/reject_invitation.go new file mode 100644 index 0000000..f529894 --- /dev/null +++ b/examples/reject_invitation/reject_invitation.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.RejectInvitation(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Reject contact invitation - api/v1/invitations/%s", paymail)) +} diff --git a/examples/revoke_access_key/revoke_access_key.go b/examples/revoke_access_key/revoke_access_key.go new file mode 100644 index 0000000..c5fc43e --- /dev/null +++ b/examples/revoke_access_key/revoke_access_key.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() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeyID := "9f7efc4af8f2c9f745ca8dfa737394d810dd8828c072c7c05e07c7aae67ff790" + err = usersAPI.RevokeAccessKey(context.Background(), accessKeyID) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Revoke access key - api/v1/users/current/keys/%s", accessKeyID)) +} diff --git a/examples/send_op_return/send_op_return.go b/examples/send_op_return/send_op_return.go index 04aae50..8521dc3 100644 --- a/examples/send_op_return/send_op_return.go +++ b/examples/send_op_return/send_op_return.go @@ -1,53 +1,65 @@ -/* -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/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.ExampleConfig, examples.XPriv) if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) + log.Fatal(err) } + ctx := context.Background() metadata := map[string]any{} - opReturn := models.OpReturn{StringParts: []string{"hello", "world"}} - transactionConfig := models.TransactionConfig{Outputs: []*models.TransactionOutput{{OpReturn: &opReturn}}} + opReturn := response.OpReturn{StringParts: []string{"hello", "world"}} + draftTransactionCmd := commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: []*response.TransactionOutput{ + { + OpReturn: &opReturn, + }, + }, + }, + Metadata: metadata, + } - draftTransaction, err := client.DraftTransaction(ctx, &transactionConfig, metadata) + draftTransaction, err := usersAPI.DraftTransaction(ctx, &draftTransactionCmd) if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) + log.Fatal(err) } - fmt.Println("DraftTransaction response: ", draftTransaction) + exampleutil.Print("DraftTransaction response: ", draftTransaction) - finalized, err := client.FinalizeTransaction(draftTransaction) + finalized, err := usersAPI.FinalizeTransaction(draftTransaction) if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) + log.Fatal(err) } - transaction, err := client.RecordTransaction(ctx, finalized, draftTransaction.ID, metadata) + fmt.Println("Finalized transaction hex : ", finalized) + + recordTransactionCmd := commands.RecordTransaction{ + Hex: finalized, + Metadata: metadata, + ReferenceID: draftTransaction.ID, + } + transaction, err := usersAPI.RecordTransaction(ctx, &recordTransactionCmd) if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) + log.Fatal(err) } fmt.Println("Transaction with OP_RETURN: ", transaction) + + transactionG, err := usersAPI.Transaction(context.Background(), transaction.ID) + if err != nil { + log.Fatal(err) + } + fmt.Println("Transaction: ", transactionG) } diff --git a/examples/sync_merkleroots/sync_merkleroots.go b/examples/sync_merkleroots/sync_merkleroots.go deleted file mode 100644 index da9da73..0000000 --- a/examples/sync_merkleroots/sync_merkleroots.go +++ /dev/null @@ -1,88 +0,0 @@ -/* -Package main - sync_merkleroots example -*/ -package main - -import ( - "context" - "fmt" - "os" - "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/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 "" - } - 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, - }, - }, -} - -func getLastFiveOrFewer(merkleroots []models.MerkleRoot) []models.MerkleRoot { - startIndex := len(merkleroots) - 5 - if startIndex < 0 { - startIndex = 0 - } - - return merkleroots[startIndex:] -} - -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)) - - err = client.SyncMerkleRoots(ctx, repository) - if err != nil { - fmt.Println("Error: ", err) - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - 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)) -} diff --git a/examples/unconfirm_contact/unconfirm_contact.go b/examples/unconfirm_contact/unconfirm_contact.go new file mode 100644 index 0000000..dac8920 --- /dev/null +++ b/examples/unconfirm_contact/unconfirm_contact.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.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.UnconfirmContact(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/update_transaction_metadata/update_transaction_metadata.go b/examples/update_transaction_metadata/update_transaction_metadata.go new file mode 100644 index 0000000..d22288f --- /dev/null +++ b/examples/update_transaction_metadata/update_transaction_metadata.go @@ -0,0 +1,30 @@ +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" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transactionID := "86cafa5b-fdaa-4629-ae46-78d68d6a180b" + transaction, err := usersAPI.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ + ID: transactionID, + Metadata: map[string]any{"new_key": "new_value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP PATCH] Update transaction metadata - api/v1/transactions/%s", transactionID), transaction) +} diff --git a/examples/update_xpub_metadata/update_xpub_metadata.go b/examples/update_xpub_metadata/update_xpub_metadata.go new file mode 100644 index 0000000..7787fdc --- /dev/null +++ b/examples/update_xpub_metadata/update_xpub_metadata.go @@ -0,0 +1,27 @@ +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" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := usersAPI.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{ + Metadata: map[string]any{"key": "value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP PATCH] Current user xPub metadata update - api/v1/users/current", xPub) +} diff --git a/examples/utils.go b/examples/utils.go deleted file mode 100644 index 0fb323e..0000000 --- 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 3dc1b64..0000000 --- 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 deleted file mode 100644 index 2332ea3..0000000 --- a/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - xpriv_from_mnemonic example -*/ -package main - -import ( - "fmt" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -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) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Println("extracted xPriv: ", keys.XPriv()) -} diff --git a/examples/xpub_from_xpriv/xpub_from_xpriv.go b/examples/xpub_from_xpriv/xpub_from_xpriv.go deleted file mode 100644 index 83e077d..0000000 --- a/examples/xpub_from_xpriv/xpub_from_xpriv.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - xpub_from_xpriv example -*/ -package main - -import ( - "fmt" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func main() { - // This is an example xPriv key - replace it with your own - const xPriv = "xprv9s21ZrQH143K4VneY3UWCF1o5Kk2tmgGrGtMtsrThCTsHsszEZ6H1iP37ZTwuUBvMwudG68SRkcfTjeu8h3rkayfyqkjKAStFBkuNsBnAkS" - - keys, err := xpriv.FromString(xPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Println("extracted xPub: ", keys.XPub().String()) -} diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go deleted file mode 100644 index 12990b5..0000000 --- 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 fa6c009..0000000 --- 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 430bdc6..0000000 --- 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 d565b59..0582763 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,20 @@ require ( ) 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 19d6f61..263f065 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,17 @@ 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.30 h1:ISfi6nkJ+hHGexkI89bAUjT44SPWNW82qE6QKa90bIs= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.30/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= +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= @@ -18,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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.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 a896026..0000000 --- a/http.go +++ /dev/null @@ -1,1170 +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.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 = 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.ContactFilter, metadata map[string]any, queryParams *filter.QueryParams) (*models.SearchContactsResponse, error) { - return Search[filter.ContactFilter, *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) -} - -// 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) -} - -// 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/users/userstest/get_xpubs_200.json b/internal/api/v1/admin/users/userstest/get_xpubs_200.json new file mode 100644 index 0000000..ff1602e --- /dev/null +++ b/internal/api/v1/admin/users/userstest/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/users/userstest/post_xpub_201.json b/internal/api/v1/admin/users/userstest/post_xpub_201.json new file mode 100644 index 0000000..cbbcb84 --- /dev/null +++ b/internal/api/v1/admin/users/userstest/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/users/userstest/xpub_api_fixtures.go b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go new file mode 100644 index 0000000..af20319 --- /dev/null +++ b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go @@ -0,0 +1,88 @@ +package userstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} + +func ExpectedXPub(t *testing.T) *response.Xpub { + return &response.Xpub{ + Model: response.Model{ + CreatedAt: parseTime(t, "2024-11-22T07:51:37.708754Z"), + UpdatedAt: 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: parseTime(t, "2024-11-21T11:41:49.830635Z"), + UpdatedAt: 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: parseTime(t, "2024-11-21T11:26:43.091808Z"), + UpdatedAt: 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, + }, + } +} + +func Ptr[T any](value T) *T { + return &value +} + +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 +} diff --git a/internal/api/v1/admin/users/xpub_filter_builder.go b/internal/api/v1/admin/users/xpub_filter_builder.go new file mode 100644 index 0000000..d07c409 --- /dev/null +++ b/internal/api/v1/admin/users/xpub_filter_builder.go @@ -0,0 +1,30 @@ +package xpubs + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type xpubFilterBuilder struct { + xpubFilter filter.XpubFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (x *xpubFilterBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := x.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", x.xpubFilter.ID) + params.AddPair("currentBalance", x.xpubFilter.CurrentBalance) + return params.Values, nil +} diff --git a/internal/api/v1/admin/users/xpub_filter_builder_test.go b/internal/api/v1/admin/users/xpub_filter_builder_test.go new file mode 100644 index 0000000..4c6477c --- /dev/null +++ b/internal/api/v1/admin/users/xpub_filter_builder_test.go @@ -0,0 +1,81 @@ +package xpubs + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestXPubFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.XpubFilter + expectedParams url.Values + expectedErr error + }{ + "xpub filter: zero values": { + expectedParams: make(url.Values), + }, + "xpub filter: filter with only 'id' field set": { + filter: filter.XpubFilter{ + ID: userstest.Ptr("5505cbc3-b38f-40d4-885f-c53efd84828f"), + }, + expectedParams: url.Values{ + "id": []string{"5505cbc3-b38f-40d4-885f-c53efd84828f"}, + }, + }, + "xpub filter: filter with only 'current balance' field set": { + filter: filter.XpubFilter{ + CurrentBalance: userstest.Ptr(uint64(24)), + }, + expectedParams: url.Values{ + "currentBalance": []string{"24"}, + }, + }, + "xpub filter: all fields set": { + filter: filter.XpubFilter{ + ID: userstest.Ptr("5505cbc3-b38f-40d4-885f-c53efd84828f"), + CurrentBalance: userstest.Ptr(uint64(24)), + ModelFilter: filter.ModelFilter{ + IncludeDeleted: userstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: 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"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := xpubFilterBuilder{ + xpubFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/admin/users/xpubs_api.go b/internal/api/v1/admin/users/xpubs_api.go new file mode 100644 index 0000000..262ad8a --- /dev/null +++ b/internal/api/v1/admin/users/xpubs_api.go @@ -0,0 +1,82 @@ +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/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/admin/users" + +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.XPubQueryOption) (*queries.XPubPage, error) { + var query queries.XPubQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&xpubFilterBuilder{ + xpubFilter: query.XpubFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.XpubFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + 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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "Admin Users XPub API", + Err: err, + } +} diff --git a/internal/api/v1/admin/users/xpubs_api_test.go b/internal/api/v1/admin/users/xpubs_api_test.go new file mode 100644 index 0000000..a92e607 --- /dev/null +++ b/internal/api/v1/admin/users/xpubs_api_test.go @@ -0,0 +1,93 @@ +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/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: userstest.ExpectedXPub(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusCreated, httpmock.File("userstest/post_xpub_201.json")), + }, + "HTTP POST /api/v1/admin/users response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/admin/users str response: 500": { + expectedErr: errors.ErrUnrecognizedAPIResponse, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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: userstest.ExpectedXPubsPage(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_xpubs_200.json")), + }, + "HTTP GET /api/v1/admin/users response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/admin/users str response: 500": { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.GivenSPVAdminAPI(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // when: + got, err := wallet.XPubs(context.Background()) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/errutil/errutil.go b/internal/api/v1/errutil/errutil.go new file mode 100644 index 0000000..cd302a0 --- /dev/null +++ b/internal/api/v1/errutil/errutil.go @@ -0,0 +1,22 @@ +package errutil + +import ( + "fmt" + "net/http" +) + +type HTTPErrorFormatter struct { + Action string + API string + Err error +} + +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) +} + +func (h HTTPErrorFormatter) FormatPutErr() error { return h.Format(http.MethodPut) } +func (h HTTPErrorFormatter) FormatPatchErr() error { return h.Format(http.MethodPatch) } +func (h HTTPErrorFormatter) FormatPostErr() error { return h.Format(http.MethodPost) } +func (h HTTPErrorFormatter) FormatGetErr() error { return h.Format(http.MethodGet) } +func (h HTTPErrorFormatter) FormatDeleteErr() error { return h.Format(http.MethodDelete) } diff --git a/internal/api/v1/errutil/errutil_test.go b/internal/api/v1/errutil/errutil_test.go new file mode 100644 index 0000000..1c80854 --- /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/querybuilders/extended_url_values.go b/internal/api/v1/querybuilders/extended_url_values.go new file mode 100644 index 0000000..c9f30b1 --- /dev/null +++ b/internal/api/v1/querybuilders/extended_url_values.go @@ -0,0 +1,90 @@ +package querybuilders + +import ( + "fmt" + "net/url" + "time" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type ExtendedURLValues struct { + url.Values +} + +func (e *ExtendedURLValues) AddPair(key string, val any) { + if val == nil || len(key) == 0 { + return + } + + write := func(v any) { e.Add(key, fmt.Sprintf("%v", v)) } + writeRange := func(v filter.TimeRange) { + if v.From != nil && !v.From.IsZero() { + e.Add(fmt.Sprintf("%s[from]", key), v.From.Format(time.RFC3339)) + } + + if v.To != nil && !v.To.IsZero() { + e.Add(fmt.Sprintf("%s[to]", key), v.To.Format(time.RFC3339)) + } + } + + switch v := val.(type) { + case int: + if v > 0 { + 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 (e *ExtendedURLValues) ParseToMap() map[string]string { + m := make(map[string]string) + for k, v := range e.Values { + m[k] = v[0] + } + + return m +} + +func (e *ExtendedURLValues) Append(vv ...url.Values) { + for _, v := range vv { + for k, iv := range v { + e.Values[k] = append(e.Values[k], iv...) + } + } +} + +func NewExtendedURLValues() *ExtendedURLValues { + return &ExtendedURLValues{ + make(url.Values), + } +} diff --git a/internal/api/v1/querybuilders/extended_url_values_test.go b/internal/api/v1/querybuilders/extended_url_values_test.go new file mode 100644 index 0000000..96c3feb --- /dev/null +++ b/internal/api/v1/querybuilders/extended_url_values_test.go @@ -0,0 +1,78 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestExtendedURLValues_AddPair(t *testing.T) { + // given: + to := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + from := querybuilderstest.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 := querybuilders.NewExtendedURLValues() + params.AddPair("key1", "str") + params.AddPair("key2", 1) + params.AddPair("key3", querybuilderstest.Ptr("str_ptr")) + params.AddPair("key4", querybuilderstest.Ptr(uint64(64))) + params.AddPair("key5", querybuilderstest.Ptr(uint32(32))) + params.AddPair("key6", querybuilderstest.Ptr(bool(false))) + params.AddPair("key7", &filter.TimeRange{ + From: &from, + To: &to, + }) + + // then: + require.EqualValues(t, expectedValues, params.Values) +} + +func TestExtendedURLValues_ParseToMap(t *testing.T) { + // given: + to := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + from := querybuilderstest.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 := querybuilders.NewExtendedURLValues() + params.AddPair("key1", "str") + params.AddPair("key2", 1) + params.AddPair("key3", querybuilderstest.Ptr("str_ptr")) + params.AddPair("key4", querybuilderstest.Ptr(uint64(64))) + params.AddPair("key5", querybuilderstest.Ptr(uint32(32))) + params.AddPair("key6", querybuilderstest.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/querybuilders/metadata_filter_builder.go b/internal/api/v1/querybuilders/metadata_filter_builder.go new file mode 100644 index 0000000..ee26f72 --- /dev/null +++ b/internal/api/v1/querybuilders/metadata_filter_builder.go @@ -0,0 +1,103 @@ +package querybuilders + +import ( + "fmt" + "net/url" + "reflect" + + "github.com/bitcoin-sv/spv-wallet-go-client/errors" +) + +type Metadata map[string]any + +const DefaultMaxDepth = 100 + +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)) +} + +type MetadataFilterBuilder struct { + MaxDepth int + Metadata Metadata +} + +func (m *MetadataFilterBuilder) Build() (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 *MetadataFilterBuilder) 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 *MetadataFilterBuilder) 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 *MetadataFilterBuilder) 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 +} diff --git a/internal/api/v1/querybuilders/metadata_filter_builder_test.go b/internal/api/v1/querybuilders/metadata_filter_builder_test.go new file mode 100644 index 0000000..52d5220 --- /dev/null +++ b/internal/api/v1/querybuilders/metadata_filter_builder_test.go @@ -0,0 +1,218 @@ +package querybuilders_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/querybuilders" + "github.com/stretchr/testify/require" +) + +func TestMetadataFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + metadata querybuilders.Metadata + expectedParams url.Values + expectedErr error + depth int + }{ + "metadata: empty map": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: make(url.Values), + }, + "metadata: map entry [key]=nil": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: make(url.Values), + metadata: querybuilders.Metadata{ + "key": nil, + }, + }, + "metadata: map entries [key1]=value1, [key2]=nil": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1][]": []string{"value1"}, + }, + metadata: querybuilders.Metadata{ + "key1": []string{"value1"}, + "key2": nil, + }, + }, + "metadata: map entry [key]=value1": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key]": []string{"value1"}, + }, + metadata: querybuilders.Metadata{ + "key": "value1", + }, + }, + "metadata: map entries [key1]=value1, [key2]=1024": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2]": []string{"1024"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + "metadata: two keys nested in one": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1][key2]": []string{"value1"}, + "metadata[key1][key3]": []string{"1024"}, + }, + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": "value1", + "key3": 1024, + }, + }, + }, + "metadata: map entries [hey=123&522]=value1, [key2]=value=123": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[hey=123&522]": []string{"value1"}, + "metadata[key2]": []string{"value=123"}, + }, + metadata: querybuilders.Metadata{ + "hey=123&522": "value1", + "key2": "value=123", + }, + }, + "metadata: map entries [key1]=value1, [key2]=[]{value2,value3,value4}": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2][]": []string{"value2", "value3", "value4"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": []string{"value2", "value3", "value4"}, + }, + }, + "metadata: map entries [key1]=value1, [key2]=[]{value2, value3, value4}, [key3]=value5, [key4]=[]{value6,value7,value8}": { + depth: querybuilders.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: querybuilders.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: querybuilders.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: querybuilders.Metadata{ + "key1": "value1", + "key2": []string{"value2", "value3", "value4"}, + "key3": querybuilders.Metadata{ + "key3_nested": "value5", + }, + "key4": querybuilders.Metadata{ + "key4_nested": []int{6, 7}, + }, + }, + }, + "metadata: 11 map entries, complex nesting, max depth set to 100": { + depth: querybuilders.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: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key3": querybuilders.Metadata{ + "key1": "abc", + "key2": querybuilders.Metadata{ + "key1": 9, + }, + "key3": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key1": []int{1, 2, 3, 4}, + "key2": 10, + "key3": "abc", + "key4": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key1": 2, + "key2": "cde", + "key3": querybuilders.Metadata{ + "key1": []int{5, 6, 7, 8}, + "key2": []string{"a", "b", "c"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "metadata: map entries depth exceeded - map entries: 4, max depth: 3": { + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key3": querybuilders.Metadata{ + "key4": "value1", + }, + }, + }, + }, + depth: 3, + expectedErr: errors.ErrMetadataFilterMaxDepthExceeded, + }, + "metadata: unsupported map in array": { + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": []any{ + querybuilders.Metadata{ + "key3": "value1", + }, + }, + }, + }, + depth: querybuilders.DefaultMaxDepth, + expectedErr: errors.ErrMetadataWrongTypeInArray, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.MetadataFilterBuilder{ + MaxDepth: tc.depth, + Metadata: tc.metadata, + } + + // then: + got, err := builder.Build() + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/querybuilders/model_filter_builder.go b/internal/api/v1/querybuilders/model_filter_builder.go new file mode 100644 index 0000000..8ee09bb --- /dev/null +++ b/internal/api/v1/querybuilders/model_filter_builder.go @@ -0,0 +1,20 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type ModelFilterBuilder struct { + ModelFilter filter.ModelFilter +} + +func (m *ModelFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("includeDeleted", m.ModelFilter.IncludeDeleted) + params.AddPair("createdRange", m.ModelFilter.CreatedRange) + params.AddPair("updatedRange", m.ModelFilter.UpdatedRange) + + return params.Values, nil +} diff --git a/internal/api/v1/querybuilders/model_filter_builder_test.go b/internal/api/v1/querybuilders/model_filter_builder_test.go new file mode 100644 index 0000000..45d6c36 --- /dev/null +++ b/internal/api/v1/querybuilders/model_filter_builder_test.go @@ -0,0 +1,124 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestModelFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.ModelFilter + expectedParams url.Values + expectedErr error + }{ + "model filter: filter with only 'include deleted field set": { + expectedParams: url.Values{ + "includeDeleted": []string{"true"}, + }, + filter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + }, + }, + "model filter: filter with only created range 'from' field set": { + expectedParams: url.Values{ + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter wtth only created range 'to' field set": { + expectedParams: url.Values{ + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only created range both fields set": { + expectedParams: url.Values{ + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range 'from' field set": { + expectedParams: url.Values{ + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range 'to' field set": { + expectedParams: url.Values{ + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range both fields set": { + expectedParams: url.Values{ + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: all fields set": { + expectedParams: 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"}, + }, + filter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.ModelFilterBuilder{ModelFilter: tc.filter} + + // then: + got, err := builder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/querybuilders/page_filter_builder.go b/internal/api/v1/querybuilders/page_filter_builder.go new file mode 100644 index 0000000..72d8ace --- /dev/null +++ b/internal/api/v1/querybuilders/page_filter_builder.go @@ -0,0 +1,21 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type PageFilterBuilder struct { + Page filter.Page +} + +func (p *PageFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("page", p.Page.Number) + params.AddPair("size", p.Page.Size) + params.AddPair("sort", p.Page.Sort) + params.AddPair("sortBy", p.Page.SortBy) + + return params.Values, nil +} diff --git a/internal/api/v1/querybuilders/page_filter_builder_test.go b/internal/api/v1/querybuilders/page_filter_builder_test.go new file mode 100644 index 0000000..7f04dd8 --- /dev/null +++ b/internal/api/v1/querybuilders/page_filter_builder_test.go @@ -0,0 +1,79 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestPageFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.Page + expectedParams url.Values + expectedErr error + }{ + "page filter: filter with only 'number' set": { + filter: filter.Page{ + Number: 10, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + }, + }, + "page filter: filter with only 'size' set": { + filter: filter.Page{ + Size: 20, + }, + expectedParams: url.Values{ + "size": []string{"20"}, + }, + }, + "page filter: filter with only 'sort' set": { + filter: filter.Page{ + Sort: "asc", + }, + expectedParams: url.Values{ + "sort": []string{"asc"}, + }, + }, + "page filter: filter with only 'sortBy' set": { + filter: filter.Page{ + SortBy: "key", + }, + expectedParams: url.Values{ + "sortBy": []string{"key"}, + }, + }, + "page filter: all fields set": { + filter: filter.Page{ + Number: 10, + Size: 20, + Sort: "asc", + SortBy: "key", + }, + expectedParams: url.Values{ + "sortBy": []string{"key"}, + "sort": []string{"asc"}, + "size": []string{"20"}, + "page": []string{"10"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.PageFilterBuilder{ + Page: tc.filter, + } + + // then: + got, err := builder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/querybuilders/query_builder.go b/internal/api/v1/querybuilders/query_builder.go new file mode 100644 index 0000000..4f39ad4 --- /dev/null +++ b/internal/api/v1/querybuilders/query_builder.go @@ -0,0 +1,78 @@ +package querybuilders + +import ( + "errors" + "net/url" + + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type QueryBuilderOption func(*QueryBuilder) + +func WithMetadataFilter(m Metadata) QueryBuilderOption { + return func(qb *QueryBuilder) { + if m != nil { + qb.builders = append(qb.builders, &MetadataFilterBuilder{MaxDepth: DefaultMaxDepth, Metadata: m}) + } + } +} + +func WithModelFilter(m filter.ModelFilter) QueryBuilderOption { + var zero filter.ModelFilter + return func(qb *QueryBuilder) { + if m != zero { + qb.builders = append(qb.builders, &ModelFilterBuilder{ModelFilter: m}) + } + } +} + +func WithPageFilter(p filter.Page) QueryBuilderOption { + var zero filter.Page + return func(qb *QueryBuilder) { + if p != zero { + qb.builders = append(qb.builders, &PageFilterBuilder{Page: p}) + } + } +} + +func WithFilterQueryBuilder(b FilterQueryBuilder) QueryBuilderOption { + return func(qb *QueryBuilder) { + if b != nil { + qb.builders = append(qb.builders, b) + } + } +} + +type FilterQueryBuilder interface { + Build() (url.Values, error) +} + +type QueryBuilder struct { + builders []FilterQueryBuilder +} + +func (q *QueryBuilder) Build() (*ExtendedURLValues, error) { + params := NewExtendedURLValues() + for _, builder := range q.builders { + values, err := builder.Build() + if err != nil { + return nil, errors.Join(err, goclienterr.ErrFilterQueryBuilder) + } + + if len(values) > 0 { + params.Append(values) + } + } + + return params, nil +} + +func NewQueryBuilder(opts ...QueryBuilderOption) *QueryBuilder { + var qb QueryBuilder + for _, o := range opts { + o(&qb) + } + + return &qb +} diff --git a/internal/api/v1/querybuilders/query_builder_test.go b/internal/api/v1/querybuilders/query_builder_test.go new file mode 100644 index 0000000..9d13dad --- /dev/null +++ b/internal/api/v1/querybuilders/query_builder_test.go @@ -0,0 +1,138 @@ +package querybuilders_test + +import ( + "errors" + "net/url" + "testing" + "time" + + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestQueryBuilder_Build(t *testing.T) { + type filters struct { + MetadataFilter querybuilders.Metadata + ModelFilter filter.ModelFilter + PageFilter filter.Page + } + tests := map[string]struct { + filters filters + expectedParams url.Values + expectedErr error + builder querybuilders.FilterQueryBuilder + }{ + "query bilder: empty filters": { + filters: filters{}, + expectedParams: make(url.Values), + }, + "query builder: URL values with page filter-only": { + filters: filters{ + PageFilter: filter.Page{ + Number: 10, + Size: 20, + SortBy: "id", + Sort: "asc", + }, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"id"}, + "sort": []string{"asc"}, + }, + }, + "query builder: URL values with metadata filter-only": { + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2]": []string{"1024"}, + }, + filters: filters{ + MetadataFilter: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + }, + "query builder: URL values with all filters set": { + filters: filters{ + PageFilter: filter.Page{ + Number: 10, + Size: 20, + Sort: "asc", + SortBy: "id", + }, + ModelFilter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + MetadataFilter: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"id"}, + "sort": []string{"asc"}, + "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{"1024"}, + }, + }, + "query builder: injected dependency filter query builder failure": { + filters: filters{ + PageFilter: filter.Page{ + Number: 10, + Size: 20, + Sort: "id", + SortBy: "asc", + }, + }, + builder: &filterQueryBuilderFailureStub{}, + expectedErr: goclienterr.ErrFilterQueryBuilder, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + opts := []querybuilders.QueryBuilderOption{ + querybuilders.WithMetadataFilter(tc.filters.MetadataFilter), + querybuilders.WithPageFilter(tc.filters.PageFilter), + querybuilders.WithModelFilter(tc.filters.ModelFilter), + querybuilders.WithFilterQueryBuilder(tc.builder), + } + builder := querybuilders.NewQueryBuilder(opts...) + + // then: + got, err := builder.Build() + require.ErrorIs(t, err, tc.expectedErr) + + if got != nil { + require.Equal(t, tc.expectedParams, got.Values) + } + }) + } +} + +type filterQueryBuilderFailureStub struct{} + +func (f *filterQueryBuilderFailureStub) Build() (url.Values, error) { + return nil, errors.New("filter query builder failure stub - query build op failure") +} diff --git a/internal/api/v1/querybuilders/query_params_filter_builder.go b/internal/api/v1/querybuilders/query_params_filter_builder.go new file mode 100644 index 0000000..b4526e7 --- /dev/null +++ b/internal/api/v1/querybuilders/query_params_filter_builder.go @@ -0,0 +1,20 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type QueryParamsFilterBuilder struct { + QueryParamsFilter filter.QueryParams +} + +func (q *QueryParamsFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("page", q.QueryParamsFilter.Page) + params.AddPair("size", q.QueryParamsFilter.PageSize) + params.AddPair("sortBy", q.QueryParamsFilter.OrderByField) + params.AddPair("sort", q.QueryParamsFilter.SortDirection) + return params.Values, nil +} diff --git a/internal/api/v1/querybuilders/query_params_filter_builder_test.go b/internal/api/v1/querybuilders/query_params_filter_builder_test.go new file mode 100644 index 0000000..b22e0a6 --- /dev/null +++ b/internal/api/v1/querybuilders/query_params_filter_builder_test.go @@ -0,0 +1,79 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestQueryParamsFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.QueryParams + expectedParams url.Values + expectedErr error + }{ + "query params: filter with only 'page' field set": { + filter: filter.QueryParams{ + Page: 10, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + }, + }, + "query params: filter with only 'page size' field set": { + filter: filter.QueryParams{ + PageSize: 20, + }, + expectedParams: url.Values{ + "size": []string{"20"}, + }, + }, + "query params: filter with only 'order by' field set": { + filter: filter.QueryParams{ + OrderByField: "value1", + }, + expectedParams: url.Values{ + "sortBy": []string{"value1"}, + }, + }, + "query params: filter with only 'sort by' field set": { + filter: filter.QueryParams{ + SortDirection: "asc", + }, + expectedParams: url.Values{ + "sort": []string{"asc"}, + }, + }, + "query params: all fields set": { + filter: filter.QueryParams{ + Page: 10, + PageSize: 20, + OrderByField: "value1", + SortDirection: "asc", + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"value1"}, + "sort": []string{"asc"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := querybuilders.QueryParamsFilterBuilder{ + QueryParamsFilter: tc.filter, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go b/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go new file mode 100644 index 0000000..c24928e --- /dev/null +++ b/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go @@ -0,0 +1,18 @@ +package querybuilderstest + +import ( + "testing" + "time" +) + +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 +} diff --git a/internal/api/v1/user/configs/configs_api.go b/internal/api/v1/user/configs/configs_api.go new file mode 100644 index 0000000..aa45f2f --- /dev/null +++ b/internal/api/v1/user/configs/configs_api.go @@ -0,0 +1,47 @@ +package configs + +import ( + "context" + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/configs" + +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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Shared Config API", + Err: err, + } +} diff --git a/internal/api/v1/user/configs/configs_api_test.go b/internal/api/v1/user/configs/configs_api_test.go new file mode 100644 index 0000000..35b4a6e --- /dev/null +++ b/internal/api/v1/user/configs/configs_api_test.go @@ -0,0 +1,56 @@ +package configs_test + +import ( + "context" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs/configstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("configstest/response_200_status_code.json")), + }, + "HTTP GET /api/v1/configs/shared response: 400": { + expectedErr: configstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, configstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/configs/shared str response: 500": { + expectedErr: configstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, configstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/configs/shared" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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/user/configs/configstest/configs_api_fixtures.go b/internal/api/v1/user/configs/configstest/configs_api_fixtures.go new file mode 100644 index 0000000..09fb684 --- /dev/null +++ b/internal/api/v1/user/configs/configstest/configs_api_fixtures.go @@ -0,0 +1,23 @@ +package configstest + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/configs/configstest/response_200_status_code.json b/internal/api/v1/user/configs/configstest/response_200_status_code.json new file mode 100644 index 0000000..56b93eb --- /dev/null +++ b/internal/api/v1/user/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/user/contacts/contact_filter_query_builder.go b/internal/api/v1/user/contacts/contact_filter_query_builder.go new file mode 100644 index 0000000..c4d50d7 --- /dev/null +++ b/internal/api/v1/user/contacts/contact_filter_query_builder.go @@ -0,0 +1,33 @@ +package contacts + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type contactFilterQueryBuilder struct { + modelFilterBuilder querybuilders.ModelFilterBuilder + contactFilter filter.ContactFilter +} + +func (c *contactFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := c.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", c.contactFilter.ID) + params.AddPair("fullName", c.contactFilter.FullName) + params.AddPair("paymail", c.contactFilter.Paymail) + params.AddPair("pubKey", c.contactFilter.PubKey) + params.AddPair("status", c.contactFilter.Status) + return params.Values, nil +} diff --git a/internal/api/v1/user/contacts/contact_filter_query_builder_test.go b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go new file mode 100644 index 0000000..a39e023 --- /dev/null +++ b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go @@ -0,0 +1,84 @@ +package contacts + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestContactFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.ContactFilter + expectedParams url.Values + expectedErr error + }{ + "contact filter: zero values": { + expectedParams: make(url.Values), + }, + "contact filter: filter with only 'id' field set": { + filter: filter.ContactFilter{ + ID: contactstest.Ptr("e3a1e174-cdf8-4683-b112-e198144eb9d2"), + }, + expectedParams: url.Values{ + "id": []string{"e3a1e174-cdf8-4683-b112-e198144eb9d2"}, + }, + }, + "contact filter: filter with only 'full name' field set": { + filter: filter.ContactFilter{ + FullName: contactstest.Ptr("John Doe"), + }, + expectedParams: url.Values{ + "fullName": []string{"John Doe"}, + }, + }, + "contact filter: filter with only 'paymail' field set": { + filter: filter.ContactFilter{ + Paymail: contactstest.Ptr("john.doe@test.com"), + }, + expectedParams: url.Values{ + "paymail": []string{"john.doe@test.com"}, + }, + }, + "contact filter: filter with only 'status' field set": { + filter: filter.ContactFilter{ + Status: contactstest.Ptr("confirmed"), + }, + expectedParams: url.Values{ + "status": []string{"confirmed"}, + }, + }, + "contact filter: filter with all fields set": { + filter: filter.ContactFilter{ + ID: contactstest.Ptr("e3a1e174-cdf8-4683-b112-e198144eb9d2"), + FullName: contactstest.Ptr("John Doe"), + Paymail: contactstest.Ptr("john.doe@test.com"), + Status: contactstest.Ptr("confirmed"), + }, + expectedParams: url.Values{ + "paymail": []string{"john.doe@test.com"}, + "status": []string{"confirmed"}, + "id": []string{"e3a1e174-cdf8-4683-b112-e198144eb9d2"}, + "fullName": []string{"John Doe"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := contactFilterQueryBuilder{ + contactFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} 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 0000000..a38c86e --- /dev/null +++ b/internal/api/v1/user/contacts/contacts_api.go @@ -0,0 +1,143 @@ +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/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/contacts" + +type API struct { + url *url.URL + httpClient *resty.Client +} + +func (a *API) Contacts(ctx context.Context, opts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { + var query queries.ContactQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&contactFilterQueryBuilder{ + contactFilter: query.ContactFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ + ModelFilter: query.ContactFilter.ModelFilter, + }, + }), + ) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build user contacts query params: %w", err) + } + + var result queries.UserContactsPage + _, 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.Paymail).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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Contacts API", + Err: err, + } +} 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 0000000..e713753 --- /dev/null +++ b/internal/api/v1/user/contacts/contacts_api_test.go @@ -0,0 +1,251 @@ +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/internal/api/v1/user/contacts/contactstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestContactsAPI_Contacts(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + expectedResponse *queries.UserContactsPage + expectedErr error + }{ + "HTTP GET /api/v1/contacts response: 200": { + expectedResponse: contactstest.ExpectedUserContactsPage(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contacts_200.json")), + }, + "HTTP GET /api/v1/contacts response: 400": { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/contacts str response: 500": { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := wallet.Contacts(context.Background()) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestContactsAPI_ContactWithPaymail(t *testing.T) { + paymail := "john.doe.test5@john.doe.test.4chain.space" + 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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contact_paymail_200.json")), + }, + fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 400", paymail): { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP GET /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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) { + paymail := "john.doe.test@john.doe.test.4chain.space" + 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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/put_contact_upsert_200.json")), + }, + fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 400", paymail): { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PUT /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodPut, url, tc.responder) + + // when: + got, err := wallet.UpsertContact(context.Background(), commands.UpsertContact{ + FullName: "John Doe", + Metadata: map[string]any{"example_key": "example_val"}, + Paymail: paymail, + }) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestContactsAPI_RemoveContact(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + expectedErr error + }{ + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 200", paymail): { + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 400", paymail): { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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: spvwallettest.MockPKI(t, spvwallettest.UserXPub), + } + + tests := map[string]struct { + responder httpmock.Responder + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", contact.Paymail): { + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", contact.Paymail): { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", contact.Paymail): { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + contact.Paymail + "/confirmation" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + const period = 3600 + const digits = 6 + + wrappedTransport := spvwallettest.NewTransportWrapper() + aliceClient, _ := spvwallettest.GivenSPVWalletClientWithTransport(t, wrappedTransport) + wrappedTransport.RegisterResponder(http.MethodPost, url, tc.responder) + + // 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) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + expectedErr error + }{ + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 200", paymail): { + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 400", paymail): { + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation str response: 500", paymail): { + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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 0000000..093530c --- /dev/null +++ b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go @@ -0,0 +1,105 @@ +package contactstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedUserContactsPage(t *testing.T) *queries.UserContactsPage { + return &queries.UserContactsPage{ + Content: []*response.Contact{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: 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", + } +} + +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 +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} 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 0000000..330de88 --- /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 0000000..661b3e6 --- /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 0000000..38f5b15 --- /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 0000000..237f675 --- /dev/null +++ b/internal/api/v1/user/invitations/invitations_api.go @@ -0,0 +1,56 @@ +package invitations + +import ( + "context" + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/invitations" + +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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Invitations API", + Err: err, + } +} 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 0000000..393be71 --- /dev/null +++ b/internal/api/v1/user/invitations/invitations_api_test.go @@ -0,0 +1,83 @@ +package invitations_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations/invitationstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestInvitationsAPI_AcceptInvitation(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 200", paymail): { + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 400", paymail): { + expectedErr: invitationstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, invitationstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts str response: 500", paymail): { + expectedErr: invitationstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, invitationstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 200", paymail): { + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 400", paymail): { + expectedErr: invitationstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, invitationstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s str response: 500", paymail): { + expectedErr: invitationstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, invitationstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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/invitations/invitationstest/invitations_api_fixtures.go b/internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go new file mode 100644 index 0000000..888f34b --- /dev/null +++ b/internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go @@ -0,0 +1,32 @@ +package invitationstest + +import ( + "net/http" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +func ParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} 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 0000000..6beef0f --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_api.go @@ -0,0 +1,115 @@ +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/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "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" + +// 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) + } + + queryBuilder := querybuilders.NewQueryBuilder(querybuilders.WithFilterQueryBuilder(&merkleRootsFilterQueryBuilder{query: query})) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build merkle roots query params: %w", err) + } + + 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 HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Merkle roots API", + Err: err, + } +} + +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 0000000..cd50b5c --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -0,0 +1,147 @@ +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/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_200.json")), + }, + "HTTP GET /api/v1/merkleroots response: 400": { + expectedErr: merklerootstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, merklerootstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/merkleroots str response: 500": { + expectedErr: merklerootstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, merklerootstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := spvWalletClient.MerkleRoots(context.Background()) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} + +// 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 args.Error(0) +} + +// TestSyncMerkleRoots tests the SyncMerkleRoots functionality +func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + setupMock func(mockRepo *MockMerkleRootsRepository) + expectedErr error + }{ + "Successful Sync with Pagination": { + responder: httpmock.ResponderFromMultipleResponses( + []*http.Response{ + httpmock.NewStringResponse(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_page1.json").String()), + httpmock.NewStringResponse(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_page2.json").String()), + }, + ), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("") // Start with no data + mockRepo.On("SaveMerkleRoots", mock.MatchedBy(func(roots []models.MerkleRoot) bool { + return len(roots) > 0 + })).Return(nil).Twice() // Called twice for two pages + }, + }, + "Stale LastEvaluatedKey Error": { + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_stale.json")), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("stale-key") // Simulate a stale key + mockRepo.On("SaveMerkleRoots", mock.Anything).Return(nil) + }, + expectedErr: errors.ErrStaleLastEvaluatedKey, + }, + "API Returns Error Response": { + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, merklerootstest.NewInternalServerSPVError()), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("") // No data initially + }, + expectedErr: merklerootstest.NewInternalServerSPVError(), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + mockRepo := new(MockMerkleRootsRepository) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + tc.setupMock(mockRepo) + + // when: + err := spvWalletClient.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 := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" + spvWalletClient, transport := spvwallettest.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 := spvWalletClient.SyncMerkleRoots(context.Background(), db) + + // then: + require.NoError(t, err) + require.Equal(t, expected, db.MerkleRoots) +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go new file mode 100644 index 0000000..04b66f9 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go @@ -0,0 +1,19 @@ +package merkleroots + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" +) + +type merkleRootsFilterQueryBuilder struct { + query queries.MerkleRootsQuery +} + +func (m *merkleRootsFilterQueryBuilder) Build() (url.Values, error) { + params := querybuilders.NewExtendedURLValues() + params.AddPair("batchSize", m.query.BatchSize) + params.AddPair("lastEvaluatedKey", m.query.LastEvaluatedKey) + return params.Values, nil +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go b/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go new file mode 100644 index 0000000..a9a4175 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go @@ -0,0 +1,61 @@ +package merkleroots + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/stretchr/testify/require" +) + +func TestMerklerootsFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + query queries.MerkleRootsQuery + expectedParams url.Values + expectedErr error + }{ + "merkle roots query: zero value": { + expectedParams: make(url.Values), + }, + "merkle roots query: query with 'batch size' set only": { + query: queries.MerkleRootsQuery{ + BatchSize: 10, + }, + expectedParams: url.Values{ + "batchSize": []string{"10"}, + }, + }, + "merkle roots query: query with 'last evaluated key' set only": { + query: queries.MerkleRootsQuery{ + LastEvaluatedKey: "key", + }, + expectedParams: url.Values{ + "lastEvaluatedKey": []string{"key"}, + }, + }, + "merkle roots query: all fields set": { + query: queries.MerkleRootsQuery{ + BatchSize: 10, + LastEvaluatedKey: "key", + }, + expectedParams: url.Values{ + "batchSize": []string{"10"}, + "lastEvaluatedKey": []string{"key"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := merkleRootsFilterQueryBuilder{ + query: tc.query, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, got, tc.expectedParams) + }) + } +} 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 0000000..b2671b4 --- /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 0000000..3ef31de --- /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 0000000..594c2be --- /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 0000000..a999a91 --- /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 0000000..3456c12 --- /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 0000000..bb50f0b --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go @@ -0,0 +1,54 @@ +package merklerootstest + +import ( + "net/http" + + "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: Ptr("blockHeight"), + SortDirection: Ptr("asc"), + TotalElements: 10, + Size: 20, + LastEvaluatedKey: "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6", + }, + } +} + +func Ptr[T any](value T) *T { + return &value +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} 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 0000000..c49ee97 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go @@ -0,0 +1,353 @@ +package merklerootstest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "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: Ptr("blockHeight"), + SortDirection: 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: Ptr("blockHeight"), + SortDirection: 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: Ptr("blockHeight"), + SortDirection: 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/totp/totp.go b/internal/api/v1/user/totp/totp.go new file mode 100644 index 0000000..3083545 --- /dev/null +++ b/internal/api/v1/user/totp/totp.go @@ -0,0 +1,146 @@ +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 +) + +// Client handles TOTP generation and validation. +type Client struct { + xPriv *bip32.ExtendedKey +} + +// New creates a new TOTP WalletClient. +func New(xPriv *bip32.ExtendedKey) *Client { + return &Client{xPriv: xPriv} +} + +// GenerateTotpForContact generates a time-based one-time password (TOTP) for a contact. +func (b *Client) 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 *Client) 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 *Client) 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 *Client) 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 0000000..4ed1a75 --- /dev/null +++ b/internal/api/v1/user/totp/totp_test.go @@ -0,0 +1,97 @@ +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/spvwallettest" + "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: spvwallettest.PubKey} + wc := totp.New(spvwallettest.ExtendedKey(t)) + + // 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 := totp.New(spvwallettest.ExtendedKey(t)) + + // when + _, err := wc.GenerateTotpForContact(&contact, 30, 2) + + // then + require.ErrorIs(t, err, errors.ErrContactPubKeyInvalid) + }) +} + +func TestClient_ValidateTotpForContact(t *testing.T) { + cfg := config.Config{ + Addr: spvwallettest.TestAPIAddr, + Timeout: 5 * time.Second, + } + t.Run("success", func(t *testing.T) { + // given + clientAlice, err := client.NewUserAPIWithXPriv(cfg, spvwallettest.AliceXPriv) + require.NoError(t, err) + + clientBob, err := client.NewUserAPIWithXPriv(cfg, spvwallettest.BobXPriv) + require.NoError(t, err) + + // and + aliceContact := &models.Contact{ + PubKey: spvwallettest.MockPKI(t, spvwallettest.AliceXPub), + Paymail: "alice@example.com", + } + + bobContact := &models.Contact{ + PubKey: spvwallettest.MockPKI(t, spvwallettest.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, spvwallettest.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") + }) +} diff --git a/internal/api/v1/user/transactions/transaction_filter_builder.go b/internal/api/v1/user/transactions/transaction_filter_builder.go new file mode 100644 index 0000000..e361c42 --- /dev/null +++ b/internal/api/v1/user/transactions/transaction_filter_builder.go @@ -0,0 +1,38 @@ +package transactions + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type transactionFilterBuilder struct { + TransactionFilter filter.TransactionFilter + ModelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (t *transactionFilterBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := t.ModelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", t.TransactionFilter.Id) + params.AddPair("hex", t.TransactionFilter.Hex) + params.AddPair("blockHash", t.TransactionFilter.BlockHash) + params.AddPair("blockHeight", t.TransactionFilter.BlockHeight) + params.AddPair("fee", t.TransactionFilter.Fee) + params.AddPair("numberOfInputs", t.TransactionFilter.NumberOfInputs) + params.AddPair("numberOfOutputs", t.TransactionFilter.NumberOfOutputs) + params.AddPair("draftId", t.TransactionFilter.DraftID) + params.AddPair("totalValue", t.TransactionFilter.TotalValue) + params.AddPair("status", t.TransactionFilter.Status) + return params.Values, nil +} diff --git a/internal/api/v1/user/transactions/transaction_filter_builder_test.go b/internal/api/v1/user/transactions/transaction_filter_builder_test.go new file mode 100644 index 0000000..af5f57b --- /dev/null +++ b/internal/api/v1/user/transactions/transaction_filter_builder_test.go @@ -0,0 +1,192 @@ +package transactions + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestTransactionFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.TransactionFilter + expectedParams url.Values + expectedErr error + }{ + "transaction filter: zero values": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr(""), + Hex: transactionstest.Ptr(""), + BlockHash: transactionstest.Ptr(""), + BlockHeight: transactionstest.Ptr(uint64(0)), + Fee: transactionstest.Ptr(uint64(0)), + NumberOfInputs: transactionstest.Ptr(uint32(0)), + NumberOfOutputs: transactionstest.Ptr(uint32(0)), + DraftID: transactionstest.Ptr(""), + TotalValue: transactionstest.Ptr(uint64(0)), + Status: transactionstest.Ptr(""), + }, + expectedParams: make(url.Values), + }, + "transaction filter: filter with only 'id' field set": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + }, + expectedParams: url.Values{ + "id": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + }, + }, + "transaction filter: filter with only 'hex' field set": { + filter: filter.TransactionFilter{ + Hex: transactionstest.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"), + }, + expectedParams: url.Values{ + "hex": []string{"001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"}, + }, + }, + "transaction filter: filter with only 'block hash' field set": { + filter: filter.TransactionFilter{ + BlockHash: transactionstest.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"), + }, + expectedParams: url.Values{ + "blockHash": []string{"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"}, + }, + }, + "transaction filter: filter with only 'block height' field set": { + filter: filter.TransactionFilter{ + BlockHeight: transactionstest.Ptr(uint64(839376)), + }, + expectedParams: url.Values{ + "blockHeight": []string{"839376"}, + }, + }, + "transaction filter: filter with only 'fee' field set": { + filter: filter.TransactionFilter{ + Fee: transactionstest.Ptr(uint64(1)), + }, + expectedParams: url.Values{ + "fee": []string{"1"}, + }, + }, + "transaction filter: filter with only 'number of inputs' field set": { + filter: filter.TransactionFilter{ + NumberOfInputs: transactionstest.Ptr(uint32(10)), + }, + expectedParams: url.Values{ + "numberOfInputs": []string{"10"}, + }, + }, + "transaction filter: filter with only 'number of outputs' field set": { + filter: filter.TransactionFilter{ + NumberOfOutputs: transactionstest.Ptr(uint32(20)), + }, + expectedParams: url.Values{ + "numberOfOutputs": []string{"20"}, + }, + }, + "transaction filter: filter with only 'draft id' field set": { + filter: filter.TransactionFilter{ + DraftID: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + }, + expectedParams: url.Values{ + "draftId": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + }, + }, + "transaction filter: filter with only 'total value' field set": { + filter: filter.TransactionFilter{ + TotalValue: transactionstest.Ptr(uint64(100000000)), + }, + expectedParams: url.Values{ + "totalValue": []string{"100000000"}, + }, + }, + "transaction filter: filter with only 'status' field set": { + filter: filter.TransactionFilter{ + Status: transactionstest.Ptr("RECEIVED"), + }, + expectedParams: url.Values{ + "status": []string{"RECEIVED"}, + }, + }, + "transaction filter: filter with only 'model filter' fields set": { + filter: filter.TransactionFilter{ + ModelFilter: filter.ModelFilter{ + IncludeDeleted: transactionstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: 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"}, + }, + }, + "transaction filter: all fields set": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + Hex: transactionstest.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"), + BlockHash: transactionstest.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"), + BlockHeight: transactionstest.Ptr(uint64(839376)), + Fee: transactionstest.Ptr(uint64(1)), + NumberOfInputs: transactionstest.Ptr(uint32(10)), + NumberOfOutputs: transactionstest.Ptr(uint32(20)), + DraftID: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + TotalValue: transactionstest.Ptr(uint64(100000000)), + Status: transactionstest.Ptr("RECEIVED"), + ModelFilter: filter.ModelFilter{ + IncludeDeleted: transactionstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: 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"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tfb := transactionFilterBuilder{ + TransactionFilter: tc.filter, + ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + got, err := tfb.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} 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 0000000..c35394f --- /dev/null +++ b/internal/api/v1/user/transactions/transactions_api.go @@ -0,0 +1,178 @@ +package transactions + +import ( + "context" + "fmt" + "net/url" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "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/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/transactions" + +type API struct { + url *url.URL + httpClient *resty.Client +} + +func (a *API) FinalizeTransaction(draft *response.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { + res, err := auth.GetSignedHex(draft, xPriv) + if err != nil { + return "", err + } + + return res, 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, + }) + } + + draftTransactionCmd := &commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: outputs, + }, + Metadata: r.Metadata, + } + + return a.DraftTransaction(ctx, draftTransactionCmd) +} + +func (a *API) SendToRecipients(ctx context.Context, r *commands.SendToRecipients, xPriv *bip32.ExtendedKey) (*response.Transaction, error) { + draft, err := a.DraftToRecipients(ctx, r) + if err != nil { + return nil, err + } + + var hex string + if hex, err = a.FinalizeTransaction(draft, xPriv); err != nil { + return nil, err + } + + recordTransactionCmd := &commands.RecordTransaction{ + Metadata: r.Metadata, + Hex: hex, + ReferenceID: draft.ID, + } + return a.RecordTransaction(ctx, recordTransactionCmd) +} + +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, transactionsOpts ...queries.TransactionsQueryOption) (*queries.TransactionPage, error) { + var query queries.TransactionsQuery + for _, o := range transactionsOpts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder(querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.Page), + querybuilders.WithFilterQueryBuilder(&transactionFilterBuilder{ + TransactionFilter: query.Filter, + ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.Filter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + 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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Transactions API", + Err: err, + } +} 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 0000000..372319e --- /dev/null +++ b/internal/api/v1/user/transactions/transactions_api_test.go @@ -0,0 +1,212 @@ +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/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_update_metadata_200.json")), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodPatch, url, tc.responder) + + // when: + got, err := spvWalletClient.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ + ID: ID, + Metadata: querybuilders.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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_record_201.json")), + }, + "HTTP GET /api/v1/transactions response: 400": { + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/transactions str response: 500": { + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/transactions" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodPost, url, tc.responder) + + // when: + got, err := spvWalletClient.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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_draft_200.json")), + }, + "HTTP POST /api/v1/transactions/drafts response: 400": { + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/transactions/drafts str response: 500": { + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/drafts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodPost, url, tc.responder) + + // when: + got, err := spvWalletClient.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 PATCH /api/v1/transactions/%s response: 200", ID): { + expectedResponse: transactionstest.ExpectedTransaction(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_200.json")), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := spvWalletClient.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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transactions_200.json")), + }, + "HTTP GET /api/v1/transactions response: 400": { + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/transactions str response: 500": { + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/transactions" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := spvWalletClient.Transactions(context.Background()) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_200.json new file mode 100644 index 0000000..0e5f10a --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/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/transaction_draft_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_draft_200.json new file mode 100644 index 0000000..4ceae76 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/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/transaction_record_201.json b/internal/api/v1/user/transactions/transactionstest/transaction_record_201.json new file mode 100644 index 0000000..f4c903d --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/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_update_metadata_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_update_metadata_200.json new file mode 100644 index 0000000..c5265c0 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/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/transactions_200.json b/internal/api/v1/user/transactions/transactionstest/transactions_200.json new file mode 100644 index 0000000..ebf9267 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/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/transactionstest.go b/internal/api/v1/user/transactions/transactionstest/transactionstest.go new file mode 100644 index 0000000..c3d97d4 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transactionstest.go @@ -0,0 +1,328 @@ +package transactionstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedDraftTransaction(t *testing.T) *response.DraftTransaction { + return &response.DraftTransaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-05T07:30:27.372912Z"), + Configuration: response.TransactionConfig{ + ChangeSatoshis: 98, + ChangeDestinations: []*response.Destination{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-05T07:30:14.207287Z"), + }, + Destination: response.Destination{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: 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: 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: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: 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, + }, + } +} + +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 +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/users/access_key_api.go b/internal/api/v1/user/users/access_key_api.go new file mode 100644 index 0000000..81b30ba --- /dev/null +++ b/internal/api/v1/user/users/access_key_api.go @@ -0,0 +1,107 @@ +package users + +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/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +type AccessKeyAPI struct { + url *url.URL + httpClient *resty.Client +} + +func (a *AccessKeyAPI) 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.JoinPath("keys").String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) 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("keys", ID).String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) AccessKeys(ctx context.Context, opts ...queries.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { + var query queries.AccessKeyQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&accessKeyFilterQueryBuilder{ + accessKeyFilter: query.AccessKeyFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.AccessKeyFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + 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.JoinPath("keys").String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) RevokeAccessKey(ctx context.Context, ID string) error { + _, err := a.httpClient.R(). + SetContext(ctx). + Delete(a.url.JoinPath("keys", ID).String()) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func NewAccessKeyAPI(url *url.URL, httpClient *resty.Client) *AccessKeyAPI { + return &AccessKeyAPI{ + url: url.JoinPath(route), + httpClient: httpClient, + } +} + +func AccessKeysHTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Access Keys API", + Err: err, + } +} diff --git a/internal/api/v1/user/users/access_key_api_test.go b/internal/api/v1/user/users/access_key_api_test.go new file mode 100644 index 0000000..aa7e360 --- /dev/null +++ b/internal/api/v1/user/users/access_key_api_test.go @@ -0,0 +1,165 @@ +package users_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: userstest.ExpectedCreatedAccessKey(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/post_access_key_200.json")), + }, + "HTTP POST /api/v1/users/current/keys response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/users/current/keys str response: 500": { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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) { + ID := "1fb70cc2-e9d9-41a3-842e-f71cc58d9787" + 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: userstest.ExpectedRertrivedAccessKey(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_key_200.json")), + }, + fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 400", ID): { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s str response: 500", ID): { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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: userstest.ExpectedAccessKeyPage(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_keys_200.json")), + }, + "HTTP GET /api/v1/users/current/keys response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/users/current/keys str response: 500": { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := wallet.AccessKeys(context.Background()) + + // then: + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) { + ID := "081743f7-040e-45a3-8c36-dde39001e20d" + 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: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s str response: 500", ID): { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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/users/access_key_filter_query_builder.go b/internal/api/v1/user/users/access_key_filter_query_builder.go new file mode 100644 index 0000000..6df2685 --- /dev/null +++ b/internal/api/v1/user/users/access_key_filter_query_builder.go @@ -0,0 +1,29 @@ +package users + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type accessKeyFilterQueryBuilder struct { + accessKeyFilter filter.AccessKeyFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (a *accessKeyFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := a.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("revokedRange", a.accessKeyFilter.RevokedRange) + return params.Values, nil +} diff --git a/internal/api/v1/user/users/access_key_filter_query_builder_test.go b/internal/api/v1/user/users/access_key_filter_query_builder_test.go new file mode 100644 index 0000000..0952d08 --- /dev/null +++ b/internal/api/v1/user/users/access_key_filter_query_builder_test.go @@ -0,0 +1,79 @@ +package users + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestAccessKeyFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.AccessKeyFilter + expectedParams url.Values + expectedErr error + }{ + "access key filter: zero values": { + expectedParams: make(url.Values), + }, + "access key filter: filter with only 'revoked range' field set": { + filter: filter.AccessKeyFilter{ + RevokedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + expectedParams: url.Values{ + "revokedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "revokedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + }, + "access key filter: all fields set": { + filter: filter.AccessKeyFilter{ + RevokedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + ModelFilter: filter.ModelFilter{ + IncludeDeleted: userstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: url.Values{ + "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"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := accessKeyFilterQueryBuilder{ + accessKeyFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/users/userstest/access_key_api_fixtures.go b/internal/api/v1/user/users/userstest/access_key_api_fixtures.go new file mode 100644 index 0000000..ebaf922 --- /dev/null +++ b/internal/api/v1/user/users/userstest/access_key_api_fixtures.go @@ -0,0 +1,87 @@ +package userstest + +import ( + "testing" + + "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: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: 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 := ParseTime(t, "2024-11-13T11:54:36.987563Z") + ts2 := 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: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-13T11:07:43.595835Z"), + UpdatedAt: 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: ParseTime(t, "2024-11-08T13:43:18.554228Z"), + UpdatedAt: 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/users/userstest/get_access_key_200.json b/internal/api/v1/user/users/userstest/get_access_key_200.json new file mode 100644 index 0000000..bf12135 --- /dev/null +++ b/internal/api/v1/user/users/userstest/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/users/userstest/get_access_keys_200.json b/internal/api/v1/user/users/userstest/get_access_keys_200.json new file mode 100644 index 0000000..403124a --- /dev/null +++ b/internal/api/v1/user/users/userstest/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/users/userstest/get_xpub_200.json b/internal/api/v1/user/users/userstest/get_xpub_200.json new file mode 100644 index 0000000..a9f13bf --- /dev/null +++ b/internal/api/v1/user/users/userstest/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/users/userstest/patch_xpub_metadata_200.json b/internal/api/v1/user/users/userstest/patch_xpub_metadata_200.json new file mode 100644 index 0000000..a83c12f --- /dev/null +++ b/internal/api/v1/user/users/userstest/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/users/userstest/post_access_key_200.json b/internal/api/v1/user/users/userstest/post_access_key_200.json new file mode 100644 index 0000000..b88bc81 --- /dev/null +++ b/internal/api/v1/user/users/userstest/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/users/userstest/xpub_api_fixtures.go b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go new file mode 100644 index 0000000..6f92644 --- /dev/null +++ b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go @@ -0,0 +1,74 @@ +package userstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedUpdatedXPubMetadata(t *testing.T) *response.Xpub { + return &response.Xpub{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T13:39:07.886862Z"), + UpdatedAt: 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: ParseTime(t, "2024-10-07T13:39:07.886862Z"), + UpdatedAt: 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, + } +} + +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 +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/users/xpub_api.go b/internal/api/v1/user/users/xpub_api.go new file mode 100644 index 0000000..9517fd2 --- /dev/null +++ b/internal/api/v1/user/users/xpub_api.go @@ -0,0 +1,62 @@ +package users + +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/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/users/current" + +type XPubAPI struct { + url *url.URL + httpClient *resty.Client +} + +func (x *XPubAPI) XPub(ctx context.Context) (*response.Xpub, error) { + var result response.Xpub + _, err := x.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + Get(x.url.String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (x *XPubAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) { + var result response.Xpub + _, err := x.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(cmd). + Patch(x.url.String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewXPubAPI(url *url.URL, httpClient *resty.Client) *XPubAPI { + return &XPubAPI{ + url: url.JoinPath(route), + httpClient: httpClient, + } + +} +func XPubsHTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User XPubs API", + Err: err, + } +} diff --git a/internal/api/v1/user/users/xpub_api_test.go b/internal/api/v1/user/users/xpub_api_test.go new file mode 100644 index 0000000..793ce0a --- /dev/null +++ b/internal/api/v1/user/users/xpub_api_test.go @@ -0,0 +1,90 @@ +package users_test + +import ( + "context" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: userstest.ExpectedUpdatedXPubMetadata(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/patch_xpub_metadata_200.json")), + }, + "HTTP PATCH /api/v1/users/current response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP PATCH /api/v1/users/current str response: 500": { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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: userstest.ExpectedUserXPub(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_xpub_200.json")), + }, + "HTTP GET /api/v1/users/current response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/users/current str response: 500": { + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/users/current" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.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/utxos/utxo_filter_query_builder.go b/internal/api/v1/user/utxos/utxo_filter_query_builder.go new file mode 100644 index 0000000..2eee46c --- /dev/null +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder.go @@ -0,0 +1,37 @@ +package utxos + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type utxoFilterQueryBuilder struct { + utxoFilter filter.UtxoFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (u *utxoFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := u.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("transactionId", u.utxoFilter.TransactionID) + params.AddPair("outputIndex", u.utxoFilter.OutputIndex) + params.AddPair("id", u.utxoFilter.ID) + params.AddPair("satoshis", u.utxoFilter.Satoshis) + params.AddPair("scriptPubKey", u.utxoFilter.ScriptPubKey) + params.AddPair("type", u.utxoFilter.Type) + params.AddPair("draftId", u.utxoFilter.DraftID) + params.AddPair("reservedRange", u.utxoFilter.ReservedRange) + params.AddPair("spendingTxId", u.utxoFilter.SpendingTxID) + return params.Values, nil +} diff --git a/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go new file mode 100644 index 0000000..d0fdeed --- /dev/null +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go @@ -0,0 +1,165 @@ +package utxos + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestUtoxFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.UtxoFilter + expectedParams url.Values + expectedErr error + }{ + "utxo filter: zero values": { + expectedParams: make(url.Values), + }, + "utxo filter: filter with only 'transaction id' field set": { + expectedParams: url.Values{ + "transactionId": []string{"124c2237-9b54-46c4-bf53-3cec86f7e316"}, + }, + filter: filter.UtxoFilter{ + TransactionID: utxostest.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"), + }, + }, + "utxo filter: filter with only 'output index' field set": { + expectedParams: url.Values{ + "outputIndex": []string{"32"}, + }, + filter: filter.UtxoFilter{ + OutputIndex: utxostest.Ptr(uint32(32)), + }, + }, + "utxo filter: filter with only 'id' field set": { + expectedParams: url.Values{ + "id": []string{"abb6a871-dd95-4f7a-8090-ca34cff63801"}, + }, + filter: filter.UtxoFilter{ + ID: utxostest.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"), + }, + }, + "utxo filter: filter with only 'satoshis' field set": { + expectedParams: url.Values{ + "satoshis": []string{"64"}, + }, + filter: filter.UtxoFilter{ + Satoshis: utxostest.Ptr(uint64(64)), + }, + }, + "utxo filter: filter with only 'script pub key' field set": { + expectedParams: url.Values{ + "scriptPubKey": []string{"3adec124-32eb-46f1-94f2-4949a86dbe8d"}, + }, + filter: filter.UtxoFilter{ + ScriptPubKey: utxostest.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"), + }, + }, + "utxo filter: filter with only 'type' field set": { + expectedParams: url.Values{ + "type": []string{"0f65e842-decf-4725-8ad9-877634280e4f"}, + }, + filter: filter.UtxoFilter{ + Type: utxostest.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"), + }, + }, + "utxo filter: filter with only 'draft id' field set": { + expectedParams: url.Values{ + "draftId": []string{"2453797c-4089-4078-8723-5ecb68e70bd7"}, + }, + filter: filter.UtxoFilter{ + DraftID: utxostest.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"), + }, + }, + "utxo filter: filter with only reserved range 'from' field set": { + expectedParams: url.Values{ + "reservedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only reserved range 'to' field set": { + expectedParams: url.Values{ + "reservedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only reserved range field set": { + expectedParams: url.Values{ + "reservedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "reservedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only 'spending tx id' field set": { + expectedParams: url.Values{ + "spendingTxId": []string{"7539366c-beb2-4405-8597-025bf2dc7cbd"}, + }, + filter: filter.UtxoFilter{ + SpendingTxID: utxostest.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"), + }, + }, + "utxo filter: all fields set": { + expectedParams: 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"}, + }, + filter: filter.UtxoFilter{ + SpendingTxID: utxostest.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"), + DraftID: utxostest.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"), + Type: utxostest.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"), + ScriptPubKey: utxostest.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"), + ID: utxostest.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"), + OutputIndex: utxostest.Ptr(uint32(32)), + Satoshis: utxostest.Ptr(uint64(64)), + TransactionID: utxostest.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"), + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := utxoFilterQueryBuilder{ + utxoFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ + ModelFilter: tc.filter.ModelFilter, + }, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedParams, got) + }) + } +} 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 0000000..42e4667 --- /dev/null +++ b/internal/api/v1/user/utxos/utxos_api.go @@ -0,0 +1,67 @@ +package utxos + +import ( + "context" + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/utxos" + +type API struct { + url *url.URL + httpClient *resty.Client +} + +func (a *API) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*queries.UtxosPage, error) { + var query queries.UtxoQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&utxoFilterQueryBuilder{ + utxoFilter: query.UtxoFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.UtxoFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + 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, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User UTXOs API", + Err: err, + } +} 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 0000000..e9d64de --- /dev/null +++ b/internal/api/v1/user/utxos/utxos_api_test.go @@ -0,0 +1,50 @@ +package utxos_test + +import ( + "context" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +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: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("utxostest/get_utxos_200.json")), + }, + "HTTP GET /api/v1/utxos response: 400": { + expectedErr: utxostest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, utxostest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/utxos str response: 500": { + expectedErr: utxostest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, utxostest.NewInternalServerSPVError()), + }, + } + + url := spvwallettest.TestAPIAddr + "/api/v1/utxos" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // given: + wallet, transport := spvwallettest.GivenSPVUserAPI(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + // when: + got, err := wallet.UTXOs(context.Background()) + + // 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 0000000..d13b472 --- /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 0000000..7167179 --- /dev/null +++ b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go @@ -0,0 +1,131 @@ +package utxostest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +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 +} + +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: ParseTime(t, "0001-01-01T00:00:00Z"), + UtxoPointer: response.UtxoPointer{ + TransactionID: "f365f697-3db9-44fd-bd0d-ba8e94ca63f2", + OutputIndex: 0, + }, + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-12T11:31:07.728974Z"), + UpdatedAt: ParseTime(t, "2024-11-12T11:31:07.732139Z"), + }, + Transaction: &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-12T11:31:07.72894Z"), + UpdatedAt: 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: ParseTime(t, "0001-01-01T00:00:00Z"), + UtxoPointer: response.UtxoPointer{ + TransactionID: "54ed5bcb-a964-47af-892b-1054065c28a8", + OutputIndex: 1, + }, + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-08T13:40:55.592Z"), + UpdatedAt: ParseTime(t, "2024-11-08T13:40:55.593441Z"), + }, + Transaction: &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-08T13:40:55.591986Z"), + UpdatedAt: 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, + }, + } +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/authentication.go b/internal/auth/auth.go similarity index 57% rename from authentication.go rename to internal/auth/auth.go index 1a77719..0af3c4b 100644 --- a/authentication.go +++ b/internal/auth/auth.go @@ -1,7 +1,8 @@ -package walletclient +package auth import ( "encoding/base64" + "encoding/hex" "fmt" "net/http" "time" @@ -9,63 +10,64 @@ import ( 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" + "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-go-client/internal/cryptoutil" "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" ) -// 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) { +func GetSignedHex(dt *response.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 + return "", fmt.Errorf("failed to parse hex, %w", err) } // Enrich inputs for _, draftInput := range dt.Configuration.Inputs { lockingScript, err := prepareLockingScript(&draftInput.Destination) if err != nil { - return "", err + return "", fmt.Errorf("failed to prepare locking script, %w", err) } unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination) if err != nil { - return "", err + return "", fmt.Errorf("failed to prepare unlocking script, %w", err) } - tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) + err = tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) + if err != nil { + return "", fmt.Errorf("failed to add inputs to transaction, %w", err) + } } - tx.Sign() + err = tx.Sign() + if err != nil { + return "", fmt.Errorf("failed to sign transaction, %w", err) + } return tx.String(), nil } -func prepareLockingScript(dst *models.Destination) (*script.Script, error) { +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 prepareLockingScript(dst *response.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) @@ -74,67 +76,65 @@ func prepareLockingScript(dst *models.Destination) (*script.Script, error) { return lockingScript, nil } -func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { +func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *response.Destination) (*p2pkh.P2PKH, error) { key, err := getDerivedKeyForDestination(xPriv, dst) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get derived key for destination: %w", err) } return getUnlockingScript(key) } -func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) { +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, err + 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, err + return nil, fmt.Errorf("failed to derive key for unlocking paymail input, %w", err) } } // Get the private key from the derived key - return bip32.GetPrivateKeyFromHDKey(derivedKey) + 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 } -// Generate unlocking script using private key func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { sigHashFlags := sighash.AllForkID - return p2pkh.Unlock(privateKey, &sigHashFlags) + unlocked, err := p2pkh.Unlock(privateKey, &sigHashFlags) + if err != nil { + return nil, fmt.Errorf("failed to create unlocking script, %w", err) + } + + return unlocked, nil } -// 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 + 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 + 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 = utils.DeriveChildKeyFromHex( - xPriv, payload.AuthNonce, - ); err != nil { + if key, err = cryptoutil.DeriveChildKeyFromHex(xPriv, payload.AuthNonce); err != nil { return } @@ -142,15 +142,12 @@ func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *mode 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) - + 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() @@ -158,37 +155,46 @@ func createSignatureCommon(payload *models.AuthPayload, bodyString string, priva if key == "" && payload.AccessKey != "" { key = payload.AccessKey } - // Signature, using bitcoin signMessage - sigBytes, err := bsm.SignMessage( - privateKey, - getSigningMessage(key, payload), - ) + sigBytes, err := bsm.SignMessage(privateKey, getSigningMessage(key, payload)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to sign message, %w", 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) } + +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 0000000..476a0ba --- /dev/null +++ b/internal/auth/authenitcators.go @@ -0,0 +1,120 @@ +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" + "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 *bip32.ExtendedKey) (*XprivAuthenticator, error) { + if xpriv == nil { + return nil, ErrBip32ExtendedKey + } + + x := XprivAuthenticator{ + xpriv: xpriv, + xpubAuth: &XpubAuthenticator{hdKey: xpriv}, + } + return &x, nil +} + +func NewAccessKeyAuthenticator(accessKey *ec.PrivateKey) (*AccessKeyAuthenticator, error) { + if accessKey == nil { + return nil, ErrEcPrivateKey + } + + a := AccessKeyAuthenticator{ + priv: accessKey, + pub: accessKey.PubKey(), + } + return &a, nil +} + +func NewXpubOnlyAuthenticator(xpub *bip32.ExtendedKey) (*XpubAuthenticator, error) { + if xpub == nil { + return nil, ErrBip32ExtendedKey + } + + x := XpubAuthenticator{hdKey: xpub} + return &x, nil +} + +var ( + ErrBip32ExtendedKey = errors.New("authenticator failed: expected a BIP32 extended key but none was provided") + ErrEcPrivateKey = errors.New("authenticator failed: expected an EC private key but none was provided") +) diff --git a/internal/auth/authenticators_test.go b/internal/auth/authenticators_test.go new file mode 100644 index 0000000..000e945 --- /dev/null +++ b/internal/auth/authenticators_test.go @@ -0,0 +1,124 @@ +package auth_test + +import ( + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "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(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrEcPrivateKey) +} + +func TestAccessKeyAuthenticator_Authenticate(t *testing.T) { + // given: + key := spvwallettest.PrivateKey(t) + authenticator, err := auth.NewAccessKeyAuthenticator(key) + 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(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrBip32ExtendedKey) +} + +func TestXprivAuthenitcator_Authenticate(t *testing.T) { + // given: + key := spvwallettest.ExtendedKey(t) + authenticator, err := auth.NewXprivAuthenticator(key) + 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(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrBip32ExtendedKey) +} + +func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { + // given: + key := spvwallettest.ExtendedKey(t) + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + 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{spvwallettest.UserPubAccessKey}, h[xAuthKey]) +} + +func requireXpubHeaderToBeSet(t *testing.T, h http.Header) { + require.Equal(t, []string{spvwallettest.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/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go new file mode 100644 index 0000000..58bebb9 --- /dev/null +++ b/internal/cryptoutil/cryptoutil.go @@ -0,0 +1,119 @@ +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" +) + +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, 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, ErrNegativeValueNotAllowed + } + if value > math.MaxUint32 { + return 0, 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 +} + +var ( + ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded") + ErrNegativeValueNotAllowed = errors.New("negative value is not allowed") + ErrHexHashPartIntParse = errors.New("parse hex hash part to int64 failed") +) diff --git a/internal/cryptoutil/cryptoutil_test.go b/internal/cryptoutil/cryptoutil_test.go new file mode 100644 index 0000000..2475bf7 --- /dev/null +++ b/internal/cryptoutil/cryptoutil_test.go @@ -0,0 +1,195 @@ +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/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: cryptoutil.ErrNegativeValueNotAllowed, + expectedUint32: 0, + }, + "input: max value exceeded": { + input: math.MaxUint32 + 1, + expectedErr: cryptoutil.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: cryptoutil.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 0000000..c6d0550 --- /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/spvwallettest/spvwallettest.go b/internal/spvwallettest/spvwallettest.go new file mode 100644 index 0000000..c307c72 --- /dev/null +++ b/internal/spvwallettest/spvwallettest.go @@ -0,0 +1,129 @@ +package spvwallettest + +import ( + "encoding/hex" + "net/http" + "testing" + "time" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + 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 ExtendedKey(t *testing.T) *bip32.ExtendedKey { + t.Helper() + key, err := bip32.GenerateHDKeyFromString(UserXPriv) + if err != nil { + t.Fatalf("test helper - bip32 generate hd key from string: %s", err) + } + + return key +} + +func PrivateKey(t *testing.T) *ec.PrivateKey { + t.Helper() + key, err := ec.PrivateKeyFromHex(UserPrivAccessKey) + if err != nil { + t.Fatalf("test helper - ec private key from hex: %s", err) + } + + return key +} + +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 GivenSPVWalletClientWithTransport(t *testing.T, transport http.RoundTripper) (*spvwallet.UserAPI, *httpmock.MockTransport) { + t.Helper() + + // Extract the wrapped MockTransport if it's a TransportWrapper + var mockTransport *httpmock.MockTransport + if wrapper, ok := transport.(*TransportWrapper); ok { + mockTransport = wrapper.MockTransport + } else if mt, ok := transport.(*httpmock.MockTransport); ok { + mockTransport = mt + } else { + t.Fatalf("expected transport to be of type *httpmock.MockTransport or *httpmockwrapper.TransportWrapper, got %T", transport) + } + + 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, mockTransport +} + +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()) +} diff --git a/internal/spvwallettest/transportmock_wrapper.go b/internal/spvwallettest/transportmock_wrapper.go new file mode 100644 index 0000000..1f7091d --- /dev/null +++ b/internal/spvwallettest/transportmock_wrapper.go @@ -0,0 +1,58 @@ +package spvwallettest + +import ( + "fmt" + "net/http" + "sync" + + "github.com/jarcoal/httpmock" +) + +type TransportWrapper struct { + *httpmock.MockTransport + + mu sync.RWMutex + lastRequest *http.Request + lastResponse *http.Response + lastError error +} + +// NewTransportWrapper creates a new wrapper around the default httpmock.MockTransport. +func NewTransportWrapper() *TransportWrapper { + return &TransportWrapper{ + MockTransport: httpmock.NewMockTransport(), + } +} + +// RoundTrip intercepts the request and stores the response and error. +func (tw *TransportWrapper) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := tw.MockTransport.RoundTrip(req) + + tw.mu.Lock() + defer tw.mu.Unlock() + + tw.lastRequest = req + tw.lastResponse = resp + tw.lastError = err + + if err != nil { + return resp, fmt.Errorf("Round trip error - %w", err) + } + return resp, nil +} + +// GetResponse retrieves the last response and error. +func (tw *TransportWrapper) GetResponse() (*http.Response, error) { + tw.mu.RLock() + defer tw.mu.RUnlock() + + return tw.lastResponse, tw.lastError +} + +// GetRequest retrieves the last request. +func (tw *TransportWrapper) GetRequest() *http.Request { + tw.mu.RLock() + defer tw.mu.RUnlock() + + return tw.lastRequest +} diff --git a/notifications/eventsMap.go b/notifications/eventsMap.go deleted file mode 100644 index 00e0b52..0000000 --- a/notifications/eventsMap.go +++ /dev/null @@ -1,25 +0,0 @@ -package notifications - -import "sync" - -type eventsMap struct { - registered *sync.Map -} - -func newEventsMap() *eventsMap { - return &eventsMap{ - registered: &sync.Map{}, - } -} - -func (em *eventsMap) store(name string, handler *eventHandler) { - em.registered.Store(name, handler) -} - -func (em *eventsMap) load(name string) (*eventHandler, bool) { - h, ok := em.registered.Load(name) - if !ok { - return nil, false - } - return h.(*eventHandler), true -} diff --git a/notifications/interface.go b/notifications/interface.go deleted file mode 100644 index 610ea2b..0000000 --- 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 436a3ed..0000000 --- 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/registerer.go deleted file mode 100644 index edaf2e9..0000000 --- a/notifications/registerer.go +++ /dev/null @@ -1,27 +0,0 @@ -package notifications - -import ( - "reflect" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -type eventHandler struct { - Caller reflect.Value - ModelType reflect.Type -} - -// RegisterHandler - registers a handler for a specific event type -func RegisterHandler[EventType models.Events](nd *Webhook, handlerFunction func(event *EventType)) error { - handlerValue := reflect.ValueOf(handlerFunction) - - modelType := handlerValue.Type().In(0).Elem() - name := modelType.Name() - - nd.handlers.store(name, &eventHandler{ - Caller: handlerValue, - ModelType: modelType, - }) - - return nil -} diff --git a/notifications/webhook.go b/notifications/webhook.go deleted file mode 100644 index 0dd488d..0000000 --- a/notifications/webhook.go +++ /dev/null @@ -1,100 +0,0 @@ -package notifications - -import ( - "context" - "encoding/json" - "net/http" - "reflect" - "time" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// Webhook - the webhook event receiver -type Webhook struct { - URL string - options *WebhookOptions - buffer chan *models.RawEvent - subscriber WebhookSubscriber - handlers *eventsMap -} - -// NewWebhook - creates a new webhook -func NewWebhook(subscriber WebhookSubscriber, url string, opts ...WebhookOpts) *Webhook { - options := NewWebhookOptions() - for _, opt := range opts { - opt(options) - } - - wh := &Webhook{ - URL: url, - options: options, - buffer: make(chan *models.RawEvent, options.BufferSize), - subscriber: subscriber, - handlers: newEventsMap(), - } - for i := 0; i < options.Processors; i++ { - go wh.process() - } - return wh -} - -// 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) -} - -// Unsubscribe - sends an unsubscription request to the spv-wallet -func (w *Webhook) Unsubscribe(ctx context.Context) error { - return w.subscriber.AdminUnsubscribeWebhook(ctx, w.URL) -} - -// HTTPHandler - returns an http handler for the webhook; it should be registered with the http server -func (w *Webhook) HTTPHandler() http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if w.options.TokenHeader != "" && r.Header.Get(w.options.TokenHeader) != w.options.TokenValue { - http.Error(rw, "Unauthorized", http.StatusUnauthorized) - return - } - var events []*models.RawEvent - if err := json.NewDecoder(r.Body).Decode(&events); err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for _, event := range events { - select { - case w.buffer <- event: - // event sent - case <-r.Context().Done(): - // request context canceled - return - case <-w.options.RootContext.Done(): - // root context canceled - the whole event processing has been stopped - return - case <-time.After(1 * time.Second): - // timeout, most probably the channel is full - } - } - rw.WriteHeader(http.StatusOK) - }) -} - -func (w *Webhook) process() { - for { - select { - case event := <-w.buffer: - handler, ok := w.handlers.load(event.Type) - if !ok { - continue - } - model := reflect.New(handler.ModelType).Interface() - if err := json.Unmarshal(event.Content, model); err != nil { - continue - } - handler.Caller.Call([]reflect.Value{reflect.ValueOf(model)}) - case <-w.options.RootContext.Done(): - return - } - } -} diff --git a/queries/access_key.go b/queries/access_key.go new file mode 100644 index 0000000..8f92b36 --- /dev/null +++ b/queries/access_key.go @@ -0,0 +1,47 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// 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] + +// AccessKeyQuery aggregates query parameters for constructing the search access key endpoint URL. +// It holds filters for metadata, pagination, and specific access key attributes. +type AccessKeyQuery struct { + Metadata map[string]any // Metadata filters for the search. + PageFilter filter.Page // Pagination details (page number, size, sorting). + AccessKeyFilter filter.AccessKeyFilter // Filters for access key properties (date ranges, deletion status). +} + +// AccessKeyQueryOption defines a functional option for configuring an AccessKeyQuery instance. +type AccessKeyQueryOption func(*AccessKeyQuery) + +// AccessKeyQueryWithMetadataFilter adds metadata filters to the search access key endpoint URL. +// The specified metadata attributes will be appended as query parameters. +func AccessKeyQueryWithMetadataFilter(m map[string]any) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.Metadata = m + } +} + +// AccessKeyQueryWithAccessKeyFilter adds filters for access key properties, such as date ranges +// (created, updated, revoked) and a flag indicating deletion status. These will be appended +// as query parameters to the search access key endpoint URL. +func AccessKeyQueryWithAccessKeyFilter(f filter.AccessKeyFilter) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.AccessKeyFilter = f + } +} + +// AccessKeyQueryWithPageFilter adds pagination details, such as page number, page size, and sorting +// options, to the search access key endpoint URL as query parameters. +func AccessKeyQueryWithPageFilter(f filter.Page) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.PageFilter = f + } +} diff --git a/queries/contacts.go b/queries/contacts.go new file mode 100644 index 0000000..68a58f8 --- /dev/null +++ b/queries/contacts.go @@ -0,0 +1,46 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// UserContactsPage 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 UserContactsPage = response.PageModel[response.Contact] + +// ContactQuery aggregates query parameters for constructing the user contacts endpoint URL. +// It contains filters for metadata, pagination, and user contact-specific attributes. +type ContactQuery struct { + Metadata map[string]any // Metadata filters for refining the search. + PageFilter filter.Page // Pagination details, including page number, size, and sorting. + ContactFilter filter.ContactFilter // Filters for contact attributes (paymail, public key, ID, status). +} + +// ContactQueryOption defines a functional option for configuring a ContactQuery instance. +// These options allow flexible setup of filters and pagination for the query. +type ContactQueryOption func(*ContactQuery) + +// ContactQueryWithMetadataFilter adds metadata filters to the user contacts search URL. +// The provided metadata attributes are appended as query parameters. +func ContactQueryWithMetadataFilter(m map[string]any) ContactQueryOption { + return func(cq *ContactQuery) { + cq.Metadata = m + } +} + +// ContactQueryWithPageFilter adds pagination settings, like page number, size, and sorting options, +// to the user contacts search URL as query parameters. +func ContactQueryWithPageFilter(f filter.Page) ContactQueryOption { + return func(cq *ContactQuery) { + cq.PageFilter = f + } +} + +// ContactQueryWithContactFilter adds filters for user contact attributes, such as paymail, public key, +// contact ID, and status. These filters are appended as query parameters to the user contacts search URL. +func ContactQueryWithContactFilter(cf filter.ContactFilter) ContactQueryOption { + return func(cq *ContactQuery) { + cq.ContactFilter = cf + } +} diff --git a/queries/merkle_roots.go b/queries/merkle_roots.go new file mode 100644 index 0000000..fae2350 --- /dev/null +++ b/queries/merkle_roots.go @@ -0,0 +1,38 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models" +) + +// 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/queries/transactions.go b/queries/transactions.go new file mode 100644 index 0000000..b81a34d --- /dev/null +++ b/queries/transactions.go @@ -0,0 +1,46 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// 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] + +// TransactionsQuery aggregates query parameters for constructing a transactions endpoint URL. +// It holds filters for metadata, transaction-specific attributes, and pagination. +type TransactionsQuery struct { + Metadata map[string]any // Metadata filters for the transactions. + Filter filter.TransactionFilter // Transaction-specific filters (e.g., block height, status). + Page filter.Page // Pagination details (page number, size, sorting). +} + +// TransactionsQueryOption defines a functional option for configuring a TransactionsQuery instance. +type TransactionsQueryOption func(*TransactionsQuery) + +// TransactionsQueryWithMetadataFilter adds metadata filters to the transactions endpoint URL. +// The specified metadata attributes will be appended as query parameters. +func TransactionsQueryWithMetadataFilter(m map[string]any) TransactionsQueryOption { + return func(tq *TransactionsQuery) { + tq.Metadata = m + } +} + +// TransactionsQueryWithFilter adds transaction-specific filters, such as block height, block hash, +// transaction status, etc., to the transactions endpoint URL as query parameters. +func TransactionsQueryWithFilter(tf filter.TransactionFilter) TransactionsQueryOption { + return func(tq *TransactionsQuery) { + tq.Filter = tf + } +} + +// TransactionsQueryWithPageFilter adds pagination details, like page number, page size, and sort order, +// to the transactions endpoint URL as query parameters. +func TransactionsQueryWithPageFilter(pf filter.Page) TransactionsQueryOption { + return func(tq *TransactionsQuery) { + tq.Page = pf + } +} diff --git a/queries/utxos.go b/queries/utxos.go new file mode 100644 index 0000000..6856789 --- /dev/null +++ b/queries/utxos.go @@ -0,0 +1,46 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// 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] + +// UtxoQuery aggregates query parameters for constructing the UTXOs search endpoint URL. +// It includes filters for metadata, pagination, and specific UTXO attributes. +type UtxoQuery struct { + Metadata map[string]any // Filters based on metadata attributes. + PageFilter filter.Page // Pagination details, such as page number, size, and sort order. + UtxoFilter filter.UtxoFilter // Filters for UTXO properties (e.g., date ranges, transaction ID, satoshis, status). +} + +// UtxoQueryOption defines a functional option for configuring a UtxoQuery instance. +type UtxoQueryOption func(*UtxoQuery) + +// UtxoQueryWithMetadataFilter applies metadata filters to the UTXOs search endpoint URL. +// The provided metadata attributes are added as query parameters. +func UtxoQueryWithMetadataFilter(m map[string]any) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.Metadata = m + } +} + +// UtxoQueryWithPageFilter sets pagination details for the UTXOs search endpoint URL. +// This includes page number, page size, and sort order, added as query parameters. +func UtxoQueryWithPageFilter(pf filter.Page) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.PageFilter = pf + } +} + +// UtxoQueryWithUtxoFilter applies filters for UTXO properties to the search endpoint URL. +// These include reserved date ranges (e.g., created, updated), transaction ID, output index, +// satoshis, etc., added as query parameters. +func UtxoQueryWithUtxoFilter(uf filter.UtxoFilter) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.UtxoFilter = uf + } +} diff --git a/queries/xpubs.go b/queries/xpubs.go new file mode 100644 index 0000000..e63429f --- /dev/null +++ b/queries/xpubs.go @@ -0,0 +1,47 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// XPubPage represents a paginated response model containing XPubs, +// as provided by the SPV Wallet API. +type XPubPage = response.PageModel[response.Xpub] + +// XPubQuery defines the query parameters used to construct the XPubs search endpoint URL. +// It includes filters for metadata, pagination, and attributes specific to XPubs and UTXOs. +type XPubQuery struct { + Metadata map[string]any // Filters based on key-value pairs of metadata. + PageFilter filter.Page // Pagination settings, including page number, size, and sort order. + XpubFilter filter.XpubFilter // Filters for XPub properties, such as ID, balance, and date ranges. +} + +// XPubQueryOption specifies a functional option for configuring an XPubQuery instance. +type XPubQueryOption func(*XPubQuery) + +// XPubQueryWithMetadataFilter applies metadata filters to the XPubQuery instance. +// The specified key-value pairs will be added as query parameters to the search URL. +func XPubQueryWithMetadataFilter(m map[string]any) XPubQueryOption { + return func(xq *XPubQuery) { + xq.Metadata = m + } +} + +// XPubQueryWithPageFilter applies pagination settings to the XPubQuery instance. +// These include details like page number, page size, and sort order, which will be +// appended as query parameters to the search URL. +func XPubQueryWithPageFilter(f filter.Page) XPubQueryOption { + return func(xq *XPubQuery) { + xq.PageFilter = f + } +} + +// XPubQueryWithXPubFilter applies XPub-specific filters to the XPubQuery instance. +// This includes filters for attributes like ID, balance, or date ranges, which will +// be added as query parameters to the search URL. +func XPubQueryWithXPubFilter(f filter.XpubFilter) XPubQueryOption { + return func(xq *XPubQuery) { + xq.XpubFilter = f + } +} diff --git a/regression_tests/Taskfile.yml b/regression_tests/Taskfile.yml deleted file mode 100644 index 8c3e5be..0000000 --- a/regression_tests/Taskfile.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3" - -tasks: - default: - cmds: - - task -l - - run_regression_tests: - desc: "running go regression tests" - cmds: - - echo "running go regression tests..." - - go test -tags=regression ./... - dir: . - env: - CLIENT_ONE_URL: "{{.CLIENT_ONE_URL}}" - CLIENT_TWO_URL: "{{.CLIENT_TWO_URL}}" - CLIENT_ONE_LEADER_XPRIV: "{{.CLIENT_ONE_LEADER_XPRIV}}" - CLIENT_TWO_LEADER_XPRIV: "{{.CLIENT_TWO_LEADER_XPRIV}}" diff --git a/regression_tests/regression_test.go b/regression_tests/regression_test.go deleted file mode 100644 index ce2a573..0000000 --- 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/utils.go b/regression_tests/utils.go deleted file mode 100644 index 3ce1158..0000000 --- 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 aa23dcb..0000000 --- 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 a69ee2a..0000000 --- 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 5b4c782..0000000 --- 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 babeb08..0000000 --- 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 d2c3e24..0000000 --- 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.SerializeCompressed()) -} diff --git a/transactions_test.go b/transactions_test.go deleted file mode 100644 index 7bc6175..0000000 --- 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 0000000..6f1bda0 --- /dev/null +++ b/user_api.go @@ -0,0 +1,496 @@ +package spvwallet + +import ( + "context" + "errors" + "fmt" + "net/url" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "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/user/configs" + "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/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/users" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" + "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/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 { + xPriv *bip32.ExtendedKey + xpubAPI *users.XPubAPI + accessKeyAPI *users.AccessKeyAPI + configsAPI *configs.API + merkleRootsAPI *merkleroots.API + contactsAPI *contacts.API + invitationsAPI *invitations.API + transactionsAPI *transactions.API + utxosAPI *utxos.API + totp *totp.Client //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.UserContactsPage. +// Returns an error if the API request fails or the response cannot be decoded. +func (u *UserAPI) Contacts(ctx context.Context, contactOpts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { + res, err := u.contactsAPI.Contacts(ctx, contactOpts...) + if err != nil { + return nil, contacts.HTTPErrorFormatter("retrieve contact", 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, contacts.HTTPErrorFormatter("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, contacts.HTTPErrorFormatter("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 contacts.HTTPErrorFormatter("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 contacts.HTTPErrorFormatter("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 contacts.HTTPErrorFormatter("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 invitations.HTTPErrorFormatter("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 invitations.HTTPErrorFormatter("reject invitation", err).FormatDeleteErr() + } + + return nil +} + +// SharedConfig retrieves the shared configuration via the user 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, configs.HTTPErrorFormatter("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, transactions.HTTPErrorFormatter("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, transactions.HTTPErrorFormatter(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, transactions.HTTPErrorFormatter(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.TransactionsQueryOption) (*queries.TransactionPage, error) { + res, err := u.transactionsAPI.Transactions(ctx, opts...) + if err != nil { + return nil, transactions.HTTPErrorFormatter("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("record a transaction with ID: %s", ID) + return nil, transactions.HTTPErrorFormatter(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 `auth.GetSignedHex` 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, u.xPriv) + 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, u.xPriv) + if err != nil { + return nil, transactions.HTTPErrorFormatter("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, users.XPubsHTTPErrorFormatter("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, users.XPubsHTTPErrorFormatter("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, users.AccessKeysHTTPErrorFormatter("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.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { + res, err := u.accessKeyAPI.AccessKeys(ctx, accessKeyOpts...) + if err != nil { + return nil, users.AccessKeysHTTPErrorFormatter("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, users.AccessKeysHTTPErrorFormatter(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 users.AccessKeysHTTPErrorFormatter(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.UtxoQueryOption) (*queries.UtxosPage, error) { + res, err := u.utxosAPI.UTXOs(ctx, opts...) + if err != nil { + return nil, utxos.HTTPErrorFormatter("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, merkleroots.HTTPErrorFormatter("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.totp == nil { + return "", errors.New("totp client not initialized - xPriv authentication required") + } + + totp, err := u.totp.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.totp == nil { + return errors.New("totp client not initialized - xPriv authentication required") + } + + if err := u.totp.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + return fmt.Errorf("failed to validate TOTP for contact: %w", err) + } + + return 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) { + key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) + } + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xPub authenticator: %w", err) + } + + return initUserAPI(cfg, nil, 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) { + key, err := bip32.GenerateHDKeyFromString(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPriv: %w", err) + } + + authenticator, err := auth.NewXprivAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xPriv authenticator: %w", err) + } + + userAPI, err := initUserAPI(cfg, key, authenticator) + if err != nil { + return nil, fmt.Errorf("failed to create new client: %w", err) + } + + userAPI.totp = totp.New(key) + 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) { + key, err := cryptoutil.PrivateKeyFromHexOrWIF(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to return private key from hex or WIF: %w", err) + } + + authenticator, err := auth.NewAccessKeyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) + } + + return initUserAPI(cfg, nil, authenticator) +} + +type authenticator interface { + Authenticate(r *resty.Request) error +} + +func initUserAPI(cfg config.Config, xPriv *bip32.ExtendedKey, 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) + return &UserAPI{ + xPriv: xPriv, + merkleRootsAPI: merkleroots.NewAPI(url, httpClient), + configsAPI: configs.NewAPI(url, httpClient), + transactionsAPI: transactions.NewAPI(url, httpClient), + utxosAPI: utxos.NewAPI(url, httpClient), + accessKeyAPI: users.NewAccessKeyAPI(url, httpClient), + xpubAPI: users.NewXPubAPI(url, httpClient), + contactsAPI: contacts.NewAPI(url, httpClient), + invitationsAPI: invitations.NewAPI(url, httpClient), + }, nil +} diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index 84649d5..0000000 --- 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 412f7d5..0000000 --- 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 399a9ff..0000000 --- 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 0000000..634f038 --- /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 0000000..acabc8b --- /dev/null +++ b/walletkeys/walletkeys.go @@ -0,0 +1,135 @@ +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 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 +} + +// 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 0000000..fe48f93 --- /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 ad4143d..0000000 --- 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 f87ca3d..0000000 --- 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 dfddea9..0000000 --- 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"]) - }) -}