diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e23af12..fbe6e77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,60 +5,160 @@ on: branches: [ main ] pull_request: branches: [ main ] - env: CARGO_TERM_COLOR: always - jobs: - test: - name: Test + lint-build-test: + name: Lint, build, unit test, docs (Linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Install Rust stable - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - profile: minimal + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt, clippy - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-stable-${{ hashFiles('**/Cargo.lock') }} + - name: Cache cargo + uses: Swatinem/rust-cache@v2 - - name: Check formatting - run: cargo fmt --all -- --check + - name: rustfmt + run: cargo fmt --all -- --check - - name: Check clippy - run: cargo clippy --all-targets --all-features -- -D warnings + - name: clippy (all targets, all features) + run: cargo clippy --all-targets --all-features -- -D warnings - - name: Run tests - run: cargo test --all-features -- --skip integration + - name: Build (all targets, all features) + run: cargo build --all-targets --all-features --locked - - name: Run doc tests - run: cargo test --doc + - name: Unit tests only (lib, bins) + run: cargo test --all-features --lib --bins -- --nocapture - - name: Build with snippets feature - run: cargo build --release --features snippets + - name: Docs build + run: cargo doc --no-deps - docs: - name: Build documentation + integration: + name: Integration tests with Selenium (Linux) runs-on: ubuntu-latest + services: + selenium: + image: selenium/standalone-chromium:140.0 + ports: + - 4444:4444 + - 7900:7900 + env: + SE_NODE_MAX_SESSIONS: 1 + options: >- + --shm-size=2g steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Prepare integration env + env: + OPEN_PAYMENTS_WALLET_ADDRESS: ${{ secrets.OPEN_PAYMENTS_WALLET_ADDRESS }} + OPEN_PAYMENTS_PRIVATE_KEY_PEM: ${{ secrets.OPEN_PAYMENTS_PRIVATE_KEY_PEM }} + OPEN_PAYMENTS_KEY_ID: ${{ secrets.OPEN_PAYMENTS_KEY_ID }} + TEST_WALLET_EMAIL: ${{ secrets.TEST_WALLET_EMAIL }} + TEST_WALLET_PASSWORD: ${{ secrets.TEST_WALLET_PASSWORD }} + run: | + set -eo pipefail + if [ -z "${OPEN_PAYMENTS_PRIVATE_KEY_PEM:-}" ] || [ -z "${OPEN_PAYMENTS_KEY_ID:-}" ] || [ -z "${TEST_WALLET_EMAIL:-}" ] || [ -z "${TEST_WALLET_PASSWORD:-}" ]; then + echo "[integration] Required secrets are missing; failing pipeline." >&2 + exit 1 + fi + mkdir -p tests/integration + PRIVATE_KEY_PATH="$GITHUB_WORKSPACE/tests/integration/private.key" + cat > tests/integration/.env < "$PRIVATE_KEY_PATH" + else + printf '%s' "$OPEN_PAYMENTS_PRIVATE_KEY_PEM" | base64 -d > "$PRIVATE_KEY_PATH" + fi + chmod 600 "$PRIVATE_KEY_PATH" + test -s "$PRIVATE_KEY_PATH" + - name: Run integration tests + env: + WEBDRIVER_URL: http://localhost:4444 + run: | + set -eo pipefail + if [ ! -s "$GITHUB_WORKSPACE/tests/integration/.env" ] || [ ! -s "$GITHUB_WORKSPACE/tests/integration/private.key" ]; then + echo "[integration] Setup artifacts missing; failing pipeline." >&2 + exit 1 + fi + cargo test --tests -- --nocapture + + security: + name: Security audit and dependency checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: cargo-audit (vulnerabilities) + uses: rustsec/audit-check@v1 + continue-on-error: true + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: cargo-deny (licenses, bans) + uses: EmbarkStudios/cargo-deny-action@v2 + continue-on-error: true + with: + command: check bans licenses sources advisories + + coverage: + name: Test coverage (tarpaulin) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 - - name: Install Rust stable - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - profile: minimal + - name: Install tarpaulin + run: | + cargo install cargo-tarpaulin --locked - - name: Build docs - run: cargo doc --no-deps --features snippets \ No newline at end of file + - name: Run coverage (unit tests only) + run: | + # run unit tests by limiting to lib and bins; generate cobertura xml at coverage.xml + cargo tarpaulin --out Xml --output-dir target/tarpaulin --timeout 1200 --packages open-payments --lib --bins --exclude-files 'src/snippets/.*' + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: target/tarpaulin/coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release-automation.yml b/.github/workflows/release-automation.yml new file mode 100644 index 0000000..4e23507 --- /dev/null +++ b/.github/workflows/release-automation.yml @@ -0,0 +1,36 @@ +name: Release Automation + +on: + workflow_dispatch: + inputs: + level: + description: "Release level (patch|minor|major)" + required: true + default: patch + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-release + run: cargo install cargo-release --locked + + - name: Run cargo-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cargo release ${{ github.event.inputs.level }} --no-dev-version --execute + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d09d49c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + publish: + name: Publish to crates.io + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Verify version matches tag + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION_PREFIX="v" + CRATE_VERSION=$(grep '^version = ' Cargo.toml | head -n1 | sed -E 's/version = "([^"]+)"/\1/') + if [ "${TAG#${VERSION_PREFIX}}" != "$CRATE_VERSION" ]; then + echo "Crate version $CRATE_VERSION does not match tag $TAG" >&2 + exit 1 + fi + + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..594bb60 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "open-payments-specifications"] + path = open-payments-specifications + url = https://github.com/interledger/open-payments-specifications diff --git a/Cargo.toml b/Cargo.toml index b794f65..144d9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ readme = "README.md" keywords = ["open-payments", "interledger", "payments", "http-signatures", "gnap"] categories = ["api-bindings", "web-programming", "asynchronous"] homepage = "https://github.com/interledger/open-payments-rust" +rust-version = "1.84" [features] default = [] @@ -156,3 +157,5 @@ tokio = { version = "1.45.0", features = ["full"] } dotenv = "0.15" tempfile = "3.20.0" uuid = { version = "1.16", features = ["v4", "serde"] } +wiremock = { version = "0.6" } +thirtyfour = { version = "0.33" } diff --git a/README.md b/README.md index a46ae34..81c8208 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ More phone numbers: https://tel.meet/htd-eefo-ovn?hs=5 ### Prerequisites -- [Rust](https://www.rust-lang.org/tools/install) (>= 1.43.1) +- [Rust](https://www.rust-lang.org/tools/install) (>= 1.70) - [Git](https://git-scm.com/downloads) ### Environment Setup @@ -125,4 +125,3 @@ For examples and snippets: [dependencies] open-payments = { version = "0.1.1", features = ["snippets"] } ``` -``` diff --git a/cargo-deny.toml b/cargo-deny.toml new file mode 100644 index 0000000..2288e35 --- /dev/null +++ b/cargo-deny.toml @@ -0,0 +1,33 @@ +[advisories] +vulnerability = "warn" +unmaintained = "warn" +yanked = "warn" +notice = "warn" +ignore = [] + +[licenses] +unlicensed = "warn" +allow = [ + "Apache-2.0", + "MIT", + "BSD-3-Clause", + "BSD-2-Clause", + "ISC", + "Unicode-DFS-2016" +] +deny = [ +] +copyleft = "allow" +confidence-threshold = 0.92 + +[bans] +multiple-versions = "warn" +wildcards = "deny" +deny = [] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" + diff --git a/open-payments-specifications b/open-payments-specifications new file mode 160000 index 0000000..d0b86f6 --- /dev/null +++ b/open-payments-specifications @@ -0,0 +1 @@ +Subproject commit d0b86f6e5b391b044e9b6d0a74615a818d4ea787 diff --git a/openapi-definitions/auth-server.yaml b/openapi-definitions/auth-server.yaml deleted file mode 100644 index eb30e62..0000000 --- a/openapi-definitions/auth-server.yaml +++ /dev/null @@ -1,541 +0,0 @@ -openapi: 3.1.0 -info: - title: Open Payments Authorization Server - version: '1.2' - license: - name: Apache 2.0 - identifier: Apache-2.0 - summary: Open Payments Authorization Server - description: 'The Open Payments API is secured via [GNAP](https://datatracker.ietf.org/doc/html/draft-ietf-gnap-core-protocol). This specification describes the Open Payments Authorization Server API, which is an opinionated GNAP Server API.' - contact: - email: tech@interledger.org -servers: - - url: 'https://auth.rafiki.money' -tags: - - name: grant - description: Grant operations - - name: token - description: Token operations -paths: - /: - post: - summary: Grant Request - operationId: post-request - responses: - '200': - description: OK - content: - application/json: - schema: - oneOf: - - properties: - interact: - $ref: '#/components/schemas/interact-response' - continue: - $ref: '#/components/schemas/continue' - required: - - interact - - continue - - properties: - access_token: - $ref: '#/components/schemas/access_token' - continue: - $ref: '#/components/schemas/continue' - required: - - access_token - - continue - type: object - examples: - Interaction instructions: - value: - interact: - redirect: 'https://auth.rafiki.money/4CF492MLVMSW9MKMXKHQ' - finish: 4105340a-05eb-4290-8739-f9e2b463bfa7 - continue: - access_token: - value: 33OMUKMKSKU80UPRY5NM - uri: 'https://auth.rafiki.money/continue/4CF492MLVMSW9MKMXKHQ' - wait: 30 - Grant: - value: - access_token: - value: OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0 - manage: 'https://auth.rafiki.money/token/dd17a202-9982-4ed9-ae31-564947fb6379' - expires_in: 3600 - access: - - type: incoming-payment - actions: - - create - - read - identifier: 'https://ilp.rafiki.money/bob' - continue: - access_token: - value: 33OMUKMKSKU80UPRY5NM - uri: 'https://auth.rafiki.money/continue/4CF492MLVMSW9MKMXKHQ' - '400': - description: Bad Request - '401': - description: Unauthorized - '500': - description: Internal Server Error - requestBody: - content: - application/json: - schema: - description: '' - type: object - properties: - access_token: - type: object - required: - - access - properties: - access: - $ref: '#/components/schemas/access' - client: - $ref: '#/components/schemas/client' - interact: - $ref: '#/components/schemas/interact-request' - required: - - access_token - - client - examples: - Grant request for creating and reading recurring fixed payment: - value: - access_token: - access: - - type: outgoing-payment - actions: - - create - - read - identifier: 'https://ilp.rafiki.money/alice' - limits: - receiver: 'https://ilp.rafiki.money/incoming-payments/45a0d0ee-26dc-4c66-89e0-01fbf93156f7' - interval: 'R12/2019-08-24T14:15:22Z/P1M' - debitAmount: - value: '500' - assetCode: USD - assetScale: 2 - client: 'https://webmonize.com/.well-known/pay' - interact: - start: - - redirect - finish: - method: redirect - uri: 'https://webmonize.com/return/876FGRD8VC' - nonce: 4edb2194-dbdf-46bb-9397-d5fd57b7c8a7 - Grant request for creating and reading incoming payments: - value: - access_token: - access: - - type: incoming-payment - actions: - - create - - read - identifier: 'http://ilp.rafiki.money/bob' - client: 'https://webmonize.com/.well-known/pay' - description: '' - description: Make a new grant request - security: [] - tags: - - grant - parameters: [] - '/continue/{id}': - parameters: - - schema: - type: string - name: id - in: path - required: true - post: - summary: Continuation Request - operationId: post-continue - responses: - '200': - description: Success - content: - application/json: - schema: - type: object - properties: - access_token: - $ref: '#/components/schemas/access_token' - continue: - $ref: '#/components/schemas/continue' - required: - - continue - examples: - Continuing After a Completed Interaction: - value: - access_token: - value: OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0 - manage: 'https://auth.rafiki.money/token/dd17a202-9982-4ed9-ae31-564947fb6379' - expires_in: 3600 - access: - - type: outgoing-payment - actions: - - create - - read - identifier: 'https://ilp.rafiki.money/alice' - limits: - receiver: 'https://ilp.rafiki.money/bob/incoming-payments/48884225-b393-4872-90de-1b737e2491c2' - interval: 'R12/2019-08-24T14:15:22Z/P1M' - debitAmount: - value: '500' - assetCode: USD - assetScale: 2 - continue: - access_token: - value: 33OMUKMKSKU80UPRY5NM - uri: 'https://auth.rafiki.money/continue/4CF492MLVMSW9MKMXKHQ' - wait: 30 - Continuing During Pending Interaction: - value: - continue: - access_token: - value: 33OMUKMKSKU80UPRY5NM - uri: 'https://auth.rafiki.money/continue/4CF492MLVMSW9MKMXKHQ' - wait: 30 - '400': - description: Bad Request - '401': - description: Unauthorized - '404': - description: Not Found - requestBody: - content: - application/json: - schema: - type: object - properties: - interact_ref: - type: string - description: |- - The interaction reference generated for this - interaction by the AS. - examples: - Interaction Reference: - value: - interact_ref: ad82597c-bbfa-4eb0-b72e-328e005b8689 - description: Continue a grant request during or after user interaction. - tags: - - grant - delete: - summary: Cancel Grant - operationId: delete-continue - responses: - '204': - description: No Content - '400': - description: Bad Request - '401': - description: Unauthorized - '404': - description: Not Found - description: Cancel a grant request or delete a grant client side. - tags: - - grant - '/token/{id}': - parameters: - - schema: - type: string - name: id - in: path - required: true - post: - summary: Rotate Access Token - operationId: post-token - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - access_token: - $ref: '#/components/schemas/access_token' - required: - - access_token - examples: - New access token: - value: - access_token: - value: OZB8CDFONP219RP1LT0OS9M2PMHKUR64TB8N6BW7 - manage: 'https://auth.rafiki.money/token/8f69de01-5bf9-4603-91ed-eeca101081f1' - expires_in: 3600 - access: - - type: outgoing-payment - actions: - - create - - read - identifier: 'https://ilp.rafiki.money/alice' - limits: - interval: 'R12/2019-08-24T14:15:22Z/P1M' - receiver: 'https://ilp.rafiki.money/bob/incoming-payments/48884225-b393-4872-90de-1b737e2491c2' - debitAmount: - value: '500' - assetCode: USD - assetScale: 2 - '400': - description: Bad Request - '401': - description: Unauthorized - '404': - description: Not Found - description: Management endpoint to rotate access token. - tags: - - token - delete: - summary: Revoke Access Token - operationId: delete-token - description: Management endpoint to revoke access token. - responses: - '204': - description: No Content - '400': - description: Bad Request - '401': - description: Unauthorized - tags: - - token -components: - schemas: - access: - type: array - description: A description of the rights associated with this access token. - items: - $ref: '#/components/schemas/access-item' - uniqueItems: true - maxItems: 3 - access-item: - oneOf: - - $ref: '#/components/schemas/access-incoming' - - $ref: '#/components/schemas/access-outgoing' - - $ref: '#/components/schemas/access-quote' - description: The access associated with the access token is described using objects that each contain multiple dimensions of access. - unevaluatedProperties: false - access-incoming: - title: access-incoming - type: object - properties: - type: - type: string - enum: - - incoming-payment - description: The type of resource request as a string. This field defines which other fields are allowed in the request object. - actions: - type: array - description: The types of actions the client instance will take at the RS as an array of strings. - items: - type: string - enum: - - create - - complete - - read - - read-all - - list - - list-all - uniqueItems: true - identifier: - type: string - format: uri - description: A string identifier indicating a specific resource at the RS. - required: - - type - - actions - access-outgoing: - title: access-outgoing - type: object - properties: - type: - type: string - enum: - - outgoing-payment - description: The type of resource request as a string. This field defines which other fields are allowed in the request object. - actions: - type: array - description: The types of actions the client instance will take at the RS as an array of strings. - items: - type: string - enum: - - create - - read - - read-all - - list - - list-all - uniqueItems: true - identifier: - type: string - format: uri - description: A string identifier indicating a specific resource at the RS. - limits: - $ref: '#/components/schemas/limits-outgoing' - required: - - type - - actions - - identifier - access-quote: - title: access-quote - type: object - properties: - type: - type: string - enum: - - quote - description: The type of resource request as a string. This field defines which other fields are allowed in the request object. - actions: - type: array - description: The types of actions the client instance will take at the RS as an array of strings. - items: - type: string - enum: - - create - - read - - read-all - uniqueItems: true - required: - - type - - actions - access_token: - title: access_token - type: object - description: A single access token or set of access tokens that the client instance can use to call the RS on behalf of the RO. - properties: - value: - type: string - description: The value of the access token as a string. The value is opaque to the client instance. The value SHOULD be limited to ASCII characters to facilitate transmission over HTTP headers within other protocols without requiring additional encoding. - manage: - type: string - format: uri - description: The management URI for this access token. This URI MUST NOT include the access token value and SHOULD be different for each access token issued in a request. - expires_in: - type: integer - description: The number of seconds in which the access will expire. The client instance MUST NOT use the access token past this time. An RS MUST NOT accept an access token past this time. - access: - $ref: '#/components/schemas/access' - required: - - value - - manage - - access - additionalProperties: false - client: - title: client - type: string - description: |- - Wallet address of the client instance that is making this request. - - When sending a non-continuation request to the AS, the client instance MUST identify itself by including the client field of the request and by signing the request. - - A JSON Web Key Set document, including the public key that the client instance will use to protect this request and any continuation requests at the AS and any user-facing information about the client instance used in interactions, MUST be available at the wallet address + `/jwks.json` url. - - If sending a grant initiation request that requires RO interaction, the wallet address MUST serve necessary client display information. - continue: - title: continue - type: object - description: 'If the AS determines that the request can be continued with additional requests, it responds with the continue field.' - properties: - access_token: - type: object - description: 'A unique access token for continuing the request, called the "continuation access token".' - required: - - value - properties: - value: - type: string - uri: - type: string - format: uri - description: The URI at which the client instance can make continuation requests. - wait: - type: integer - description: The amount of time in integer seconds the client instance MUST wait after receiving this request continuation response and calling the continuation URI. - required: - - access_token - - uri - interact-request: - title: interact - type: object - properties: - start: - type: array - description: Indicates how the client instance can start an interaction. - items: - type: string - enum: - - redirect - finish: - type: object - description: Indicates how the client instance can receive an indication that interaction has finished at the AS. - properties: - method: - type: string - enum: - - redirect - description: The callback method that the AS will use to contact the client instance. - uri: - type: string - format: uri - description: Indicates the URI that the AS will either send the RO to after interaction or send an HTTP POST request. - nonce: - type: string - description: 'Unique value to be used in the calculation of the "hash" query parameter sent to the callback URI, must be sufficiently random to be unguessable by an attacker. MUST be generated by the client instance as a unique value for this request.' - required: - - method - - uri - - nonce - required: - - start - description: The client instance declares the parameters for interaction methods that it can support using the interact field. - interact-response: - title: interact-response - type: object - properties: - redirect: - type: string - format: uri - description: The URI to direct the end user to. - finish: - type: string - description: Unique key to secure the callback. - required: - - redirect - - finish - interval: - title: Interval - type: string - description: '[ISO8601 repeating interval](https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals)' - examples: - - 'R11/2022-08-24T14:15:22Z/P1M' - - 'R/2017-03-01T13:00:00Z/2018-05-11T15:30:00Z' - - 'R-1/P1Y2M10DT2H30M/2022-05-11T15:30:00Z' - limits-outgoing: - title: limits-outgoing - description: Open Payments specific property that defines the limits under which outgoing payments can be created. - type: object - properties: - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - debitAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - receiveAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - interval: - $ref: '#/components/schemas/interval' - anyOf: - - not: - required: - - interval - - required: - - debitAmount - - required: - - receiveAmount - securitySchemes: - GNAP: - name: Authorization - type: apiKey - in: header -security: - - GNAP: [] diff --git a/openapi-definitions/resource-server.yaml b/openapi-definitions/resource-server.yaml deleted file mode 100644 index 938d112..0000000 --- a/openapi-definitions/resource-server.yaml +++ /dev/null @@ -1,1204 +0,0 @@ -# removed refs to `optional-signature` and `optional-signature-input` (in `signature` and `signature-input`) and inline instead -# to fix error on `oapi-codegen -generate types ...` -openapi: 3.1.0 -info: - title: Open Payments - version: "1.4" - license: - name: Apache 2.0 - identifier: Apache-2.0 - description: |- - The Open Payments API is a simple REST API with 4 resource types: **wallet address**, **quote**, **incoming payment** and **outgoing payment**. - - The *service endpoint* for the API is always the URL of the wallet address resource and all other resources are sub-resources of the wallet address. - - An incoming payment defines meta data that is automatically attached to payments made into the wallet address under that incoming payment. This facilitates automation of processes like reconciliation of payment into the wallet address with external systems. - - An outgoing payment is an instruction to make a payment out of the wallet address. - - A quote is a commitment from the Account Servicing Entity to deliver a particular amount to a receiver when sending a particular amount from the wallet address. It is only valid for a limited time. - - All resource and collection resource representations use JSON and the media-type `application/json`. - - The `wallet address` resource has three collections of sub-resources: - 1. `/incoming-payments` contains the **incoming payment** sub-resources - 2. `/outgoing-payments` contains the **outgoing payment** sub-resources - 3. `/quotes` contains the **quote** sub-resources - - Access to resources and permission to execute the methods exposed by the API is determined by the grants given to the client represented by an access token used in API requests. - summary: An API for open access to financial accounts to send and receive payments. - contact: - email: tech@interledger.org -servers: - - url: "https://ilp.rafiki.money" - description: "Server for wallet address subresources (ie https://ilp.rafiki.money/alice)" - - url: "https://ilp.rafiki.money/.well-known/pay" - description: "Server for when the wallet address has no pathname (ie https://ilp.rafiki.money)" -tags: - - name: wallet-address - description: wallet address operations - - name: incoming-payment - description: incoming payment operations - - name: outgoing-payment - description: outgoing payment operations - - name: quote - description: quote operations -paths: - /incoming-payments: - post: - summary: Create an Incoming Payment - tags: - - incoming-payment - operationId: create-incoming-payment - responses: - "201": - description: Incoming Payment Created - content: - application/json: - schema: - $ref: "#/components/schemas/incoming-payment-with-methods" - examples: - New Incoming Payment for $25: - value: - id: "https://ilp.rafiki.money/incoming-payments/08394f02-7b7b-45e2-b645-51d04e7c330c" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "0" - assetCode: USD - assetScale: 2 - completed: false - expiresAt: "2022-02-03T23:20:50.52Z" - metadata: - externalRef: INV2022-02-0137 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - methods: - - type: ilp - ilpAddress: g.ilp.iwuyge987y.98y08y - sharedSecret: 1c7eaXa4rd2fFOBl1iydvCT1tV5TbM3RW1WLCafu_JA - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - requestBody: - content: - application/json: - schema: - type: object - additionalProperties: false - properties: - walletAddress: - $ref: ./schemas.yaml#/components/schemas/walletAddress - incomingAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The maximum amount that should be paid into the wallet address under this incoming payment. - expiresAt: - type: string - description: The date and time when payments into the incoming payment must no longer be accepted. - format: date-time - writeOnly: true - metadata: - type: object - description: Additional metadata associated with the incoming payment. (Optional) - required: - - walletAddress - examples: - Create incoming payment for $25 to pay invoice INV2022-02-0137: - value: - walletAddress: "https://openpayments.guide/alice/" - incomingAmount: - value: "2500" - assetCode: USD - assetScale: 2 - metadata: - externalRef: INV2022-02-0137 - description: |- - A subset of the incoming payments schema is accepted as input to create a new incoming payment. - - The `incomingAmount` must use the same `assetCode` and `assetScale` as the wallet address. - required: true - description: |- - A client MUST create an **incoming payment** resource before it is possible to send any payments to the wallet address. - - When a client creates an **incoming payment** the receiving Account Servicing Entity generates unique payment details that can be used to address payments to the account and returns these details to the client as properties of the new **incoming payment**. Any payments received using those details are then associated with the **incoming payment**. - - All of the input parameters are _optional_. - - For example, the client could use the `metadata` property to store an external reference on the **incoming payment** and this can be shared with the account holder to assist with reconciliation. - - If `incomingAmount` is specified and the total received using the payment details equals or exceeds the specified `incomingAmount`, then the receiving Account Servicing Entity MUST reject any further payments and set `completed` to `true`. - - If an `expiresAt` value is defined, and the current date and time on the receiving Account Servicing Entity's systems exceeds that value, the receiving Account Servicing Entity MUST reject any further payments. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - get: - summary: List Incoming Payments - operationId: list-incoming-payments - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - pagination: - $ref: "#/components/schemas/page-info" - result: - type: array - items: - $ref: "#/components/schemas/incoming-payment" - additionalProperties: false - examples: - forward pagination: - value: - pagination: - startCursor: 241de237-f989-42be-926d-c0c1fca57708 - endCursor: 315581f8-9967-45a0-9cd3-87b60b6d6414 - hasPreviousPage: false - hasNextPage: true - result: - - id: "https://ilp.rafiki.money/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "250" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "250" - assetCode: USD - assetScale: 2 - metadata: - description: "Hi Mo, this is for the cappuccino I bought for you the other day." - externalRef: Coffee w/ Mo on 10 March 22 - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - completed: true - - id: "https://ilp.rafiki.money/incoming-payments/32abc219-3dc3-44ec-a225-790cacfca8fa" - walletAddress: "https://ilp.rafiki.money/alice/" - receivedAmount: - value: "100" - assetCode: USD - assetScale: 2 - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: "I love your website, Alice! Thanks for the great content" - completed: false - backward pagination: - value: - pagination: - startCursor: 241de237-f989-42be-926d-c0c1fca57708 - endCursor: 315581f8-9967-45a0-9cd3-87b60b6d6414 - hasPreviousPage: true - hasNextPage: false - result: - - id: "https://ilp.rafiki.money/incoming-payments/32abc219-3dc3-44ec-a225-790cacfca8fa" - walletAddress: "https://ilp.rafiki.money/alice/" - receivedAmount: - value: "100" - assetCode: USD - assetScale: 2 - completed: true - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: "I love your website, Alice! Thanks for the great content" - - id: "https://ilp.rafiki.money/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "250" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "250" - assetCode: USD - assetScale: 2 - completed: true - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: "Hi Mo, this is for the cappuccino I bought for you the other day." - externalRef: Coffee w/ Mo on 10 March 22 - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - description: List all incoming payments on the wallet address - parameters: - - $ref: "#/components/parameters/wallet-address" - - $ref: "#/components/parameters/cursor" - - $ref: "#/components/parameters/first" - - $ref: "#/components/parameters/last" - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - tags: - - incoming-payment - /outgoing-payments: - post: - summary: Create an Outgoing Payment - tags: - - outgoing-payment - operationId: create-outgoing-payment - responses: - "201": - description: Outgoing Payment Created - content: - application/json: - schema: - $ref: "#/components/schemas/outgoing-payment" - examples: - New Fixed Send Outgoing Payment for $25: - value: - id: "https://ilp.rafiki.money/outgoing-payments/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/bob/incoming-payments/48884225-b393-4872-90de-1b737e2491c2" - debitAmount: - value: "2600" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - sentAmount: - value: "0" - assetCode: USD - assetScale: 2 - metadata: - description: Thank you for the shoes. - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - requestBody: - content: - application/json: - examples: - Create an outgoing payment based on a quote: - value: - walletAddress: "https://ilp.rafiki.money/alice/" - quoteId: "https://ilp.rafiki.money/quotes/ab03296b-0c8b-4776-b94e-7ee27d868d4d" - metadata: - externalRef: INV2022-02-0137 - schema: - type: object - properties: - walletAddress: - $ref: ./schemas.yaml#/components/schemas/walletAddress - quoteId: - type: string - format: uri - description: The URL of the quote defining this payment's amounts. - metadata: - type: object - additionalProperties: true - description: Additional metadata associated with the outgoing payment. (Optional) - required: - - quoteId - - walletAddress - additionalProperties: false - description: |- - A subset of the outgoing payments schema is accepted as input to create a new outgoing payment. - - The `debitAmount` must use the same `assetCode` and `assetScale` as the wallet address. - required: true - description: |- - An **outgoing payment** is a sub-resource of a wallet address. It represents a payment from the wallet address. - - Once created, it is already authorized and SHOULD be processed immediately. If payment fails, the Account Servicing Entity must mark the **outgoing payment** as `failed`. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - description: Create a new outgoing payment at the wallet address. - get: - summary: List Outgoing Payments - operationId: list-outgoing-payments - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - pagination: - $ref: "#/components/schemas/page-info" - result: - type: array - items: - $ref: "#/components/schemas/outgoing-payment" - examples: - forward pagination: - value: - pagination: - startCursor: 241de237-f989-42be-926d-c0c1fca57708 - endCursor: 315581f8-9967-45a0-9cd3-87b60b6d6414 - hasPreviousPage: false - hasNextPage: true - result: - - id: "https://ilp.rafiki.money/outgoing-payments/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/aplusvideo/incoming-payments/45d495ad-b763-4882-88d7-aa14d261686e" - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - debitAmount: - value: "2600" - assetCode: USD - assetScale: 2 - sentAmount: - value: "2500" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: APlusVideo subscription - externalRef: "customer: 847458475" - - id: "https://ilp.rafiki.money/outgoing-payments/0cffa5a4-58fd-4cc8-8e01-7145c72bf07c" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/shoeshop/incoming-payments/2fe92c6f-ef0d-487c-8759-3784eae6bce9" - debitAmount: - value: "7126" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "7026" - assetCode: USD - assetScale: 2 - sentAmount: - value: "7026" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: Thank you for your purchase at ShoeShop! - externalRef: INV2022-8943756 - backward pagination: - value: - pagination: - startCursor: 241de237-f989-42be-926d-c0c1fca57708 - endCursor: 315581f8-9967-45a0-9cd3-87b60b6d6414 - hasPreviousPage: true - hasNextPage: false - result: - - id: "https://ilp.rafiki.money/outgoing-payments/0cffa5a4-58fd-4cc8-8e01-7145c72bf07c" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/shoeshop/incoming-payments/2fe92c6f-ef0d-487c-8759-3784eae6bce9" - debitAmount: - value: "7126" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "7026" - assetCode: USD - assetScale: 2 - sentAmount: - value: "7026" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: Thank you for your purchase at ShoeShop! - externalRef: INV2022-8943756 - - id: "https://ilp.rafiki.money/outgoing-payments/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/aplusvideo/incoming-payments/45d495ad-b763-4882-88d7-aa14d261686e" - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - debitAmount: - value: "2600" - assetCode: USD - assetScale: 2 - sentAmount: - value: "2500" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: APlusVideo subscription - externalRef: "customer: 847458475" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - description: List all outgoing payments on the wallet address - parameters: - - $ref: "#/components/parameters/wallet-address" - - $ref: "#/components/parameters/cursor" - - $ref: "#/components/parameters/first" - - $ref: "#/components/parameters/last" - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - tags: - - outgoing-payment - /quotes: - post: - summary: Create a Quote - tags: - - quote - operationId: create-quote - responses: - "201": - description: Quote Created - content: - application/json: - schema: - $ref: "#/components/schemas/quote" - examples: - New Fixed Send Quote for $25: - value: - id: "https://ilp.rafiki.money/quotes/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - receiver: "https://ilp.rafiki.money/aplusvideo/incoming-payments/45d495ad-b763-4882-88d7-aa14d261686e" - debitAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "2198" - assetCode: EUR - assetScale: 2 - method: ilp - createdAt: "2022-03-12T23:20:50.52Z" - expiresAt: "2022-04-12T23:20:50.52Z" - "400": - description: No amount was provided and no amount could be inferred from the receiver. - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - requestBody: - content: - application/json: - examples: - Create quote for an `receiver` that is an Incoming Payment with an `incomingAmount`: - value: - walletAddress: "https://ilp.rafiki.money/alice" - receiver: "https://ilp.rafiki.money/incoming-payments/37a0d0ee-26dc-4c66-89e0-01fbf93156f7" - method: ilp - Create fixed-send amount quote for $25: - value: - walletAddress: "https://ilp.rafiki.money/alice" - receiver: "https://ilp.rafiki.money/incoming-payments/37a0d0ee-26dc-4c66-89e0-01fbf93156f7" - method: ilp - debitAmount: - value: "2500" - assetCode: USD - assetScale: 2 - Create fixed-receive amount quote for $25: - value: - walletAddress: "https://ilp.rafiki.money/alice" - receiver: "https://ilp.rafiki.money/incoming-payments/37a0d0ee-26dc-4c66-89e0-01fbf93156f7" - method: ilp - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - schema: - oneOf: - - description: Create quote for an `receiver` that is an Incoming Payment with an `incomingAmount` - properties: - walletAddress: - $ref: ./schemas.yaml#/components/schemas/walletAddress - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - method: - $ref: "#/components/schemas/payment-method" - required: - - walletAddress - - receiver - - method - additionalProperties: false - - description: Create a quote with a fixed-receive amount - properties: - walletAddress: - $ref: ./schemas.yaml#/components/schemas/walletAddress - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - method: - $ref: "#/components/schemas/payment-method" - receiveAmount: - description: The fixed amount that would be paid into the receiving wallet address given a successful outgoing payment. - $ref: ./schemas.yaml#/components/schemas/amount - required: - - walletAddress - - receiver - - method - - receiveAmount - additionalProperties: false - - description: Create a quote with a fixed-send amount - properties: - walletAddress: - $ref: ./schemas.yaml#/components/schemas/walletAddress - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - method: - $ref: "#/components/schemas/payment-method" - debitAmount: - description: The fixed amount that would be sent from the sending wallet address given a successful outgoing payment. - $ref: ./schemas.yaml#/components/schemas/amount - required: - - walletAddress - - receiver - - method - - debitAmount - additionalProperties: false - description: |- - A subset of the quotes schema is accepted as input to create a new quote. - - The quote must be created with a (`debitAmount` xor `receiveAmount`) unless the `receiver` is an Incoming Payment which has an `incomingAmount`. - required: true - description: A **quote** is a sub-resource of a wallet address. It represents a quote for a payment from the wallet address. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - description: Create a new quote at the wallet address. - "/incoming-payments/{id}": - get: - summary: Get an Incoming Payment - tags: - - incoming-payment - operationId: get-incoming-payment - responses: - "200": - description: Incoming Payment Found - content: - application/json: - schema: - anyOf: - - $ref: "#/components/schemas/incoming-payment-with-methods" - - $ref: "#/components/schemas/public-incoming-payment" - examples: - Incoming Payment for $25 with $12.34 received so far: - value: - id: "https://ilp.rafiki.money/incoming-payments/2f1b0150-db73-49e8-8713-628baa4a17ff" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "1234" - assetCode: USD - assetScale: 2 - completed: false - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: Thanks for the flowers! - externalRef: INV-12876 - methods: - - type: ilp - ilpAddress: g.ilp.iwuyge987y.98y08y - sharedSecret: 1c7eaXa4rd2fFOBl1iydvCT1tV5TbM3RW1WLCafu_JA - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - description: Incoming Payment Not Found - parameters: - - $ref: "#/components/parameters/optional-signature-input" - - $ref: "#/components/parameters/optional-signature" - description: A client can fetch the latest state of an incoming payment to determine the amount received into the wallet address. - parameters: - - $ref: "#/components/parameters/id" - "/incoming-payments/{id}/complete": - post: - summary: Complete an Incoming Payment - tags: - - incoming-payment - operationId: complete-incoming-payment - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/incoming-payment" - additionalProperties: false - examples: - Completed Incoming Payment: - value: - id: "https://ilp.rafiki.money/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "250" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "250" - assetCode: USD - assetScale: 2 - completed: true - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: "Hi Mo, this is for the cappuccino I bought for you the other day." - externalRef: Coffee w/ Mo on 10 March 2 - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - description: Incoming Payment Not Found - description: |- - A client with the appropriate permissions MAY mark a non-expired **incoming payment** as `completed` indicating that the client is not going to make any further payments toward this **incoming payment**, even though the full `incomingAmount` may not have been received. - - This indicates to the receiving Account Servicing Entity that it can begin any post processing of the payment such as generating account statements or notifying the account holder of the completed payment. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - parameters: - - $ref: "#/components/parameters/id" - "/outgoing-payments/{id}": - get: - summary: Get an Outgoing Payment - tags: - - outgoing-payment - operationId: get-outgoing-payment - responses: - "200": - description: Outgoing Payment Found - content: - application/json: - schema: - $ref: "#/components/schemas/outgoing-payment" - examples: - Outgoing Payment with a fixed send amount of $25: - value: - id: "https://ilp.rafiki.money/bob/outgoing-payments/3859b39e-4666-4ce5-8745-72f1864c5371" - walletAddress: "https://ilp.rafiki.money/bob/" - failed: false - receiver: "https://ilp.rafiki.money/incoming-payments/2f1b0150-db73-49e8-8713-628baa4a17ff" - debitAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "2198" - assetCode: EUR - assetScale: 2 - sentAmount: - value: "1205" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: Thanks for the flowers! - externalRef: INV-12876 - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - description: Outgoing Payment Not Found - description: A client can fetch the latest state of an outgoing payment. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - parameters: - - $ref: "#/components/parameters/id" - "/quotes/{id}": - get: - summary: Get a Quote - tags: - - quote - operationId: get-quote - responses: - "200": - description: Quote Found - content: - application/json: - schema: - $ref: "#/components/schemas/quote" - examples: - Quote with a fixed send amount of $25: - value: - id: "https://ilp.rafiki.money/bob/quotes/3859b39e-4666-4ce5-8745-72f1864c5371" - walletAddress: "https://ilp.rafiki.money/bob/" - receiver: "https://ilp.rafiki.money/incoming-payments/2f1b0150-db73-49e8-8713-628baa4a17ff" - debitAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receiveAmount: - value: "2198" - assetCode: EUR - assetScale: 2 - method: ilp - createdAt: "2022-03-12T23:20:50.52Z" - expiresAt: "2022-04-12T23:20:50.52Z" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - description: Quote Not Found - description: A client can fetch the latest state of a quote. - parameters: - - $ref: "#/components/parameters/signature-input" - - $ref: "#/components/parameters/signature" - parameters: - - $ref: "#/components/parameters/id" -components: - schemas: - incoming-payment: - title: Incoming Payment - description: "An **incoming payment** resource represents a payment that will be, is currently being, or has been received by the account." - type: object - examples: - - id: "https://ilp.rafiki.money/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "250" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "250" - assetCode: USD - assetScale: 2 - completed: true - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: "Hi Mo, this is for the cappuccino I bought for you the other day." - externalRef: Coffee w/ Mo on 10 March 22 - - id: "https://ilp.rafiki.money/incoming-payments/456da9d5-c9a4-4c80-a354-86b915a04ff8" - walletAddress: "https://ilp.rafiki.money/alice/" - incomingAmount: - value: "2500" - assetCode: USD - assetScale: 2 - receivedAmount: - value: "0" - assetCode: USD - assetScale: 2 - expiresAt: "2022-04-12T23:20:50.52Z" - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-03-12T23:20:50.52Z" - properties: - id: - type: string - format: uri - description: The URL identifying the incoming payment. - readOnly: true - walletAddress: - type: string - format: uri - description: The URL of the wallet address this payment is being made into. - readOnly: true - completed: - type: boolean - description: Describes whether the incoming payment has completed receiving fund. - default: false - incomingAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The maximum amount that should be paid into the wallet address under this incoming payment. - receivedAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The total amount that has been paid into the wallet address under this incoming payment. - expiresAt: - type: string - description: The date and time when payments under this incoming payment will no longer be accepted. - format: date-time - metadata: - type: object - description: Additional metadata associated with the incoming payment. (Optional) - createdAt: - type: string - format: date-time - description: The date and time when the incoming payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the incoming payment was updated. - required: - - id - - walletAddress - - completed - - receivedAmount - - createdAt - - updatedAt - incoming-payment-with-methods: - title: Incoming Payment with payment methods - description: An **incoming payment** resource with public details. - allOf: - - $ref: "#/components/schemas/incoming-payment" - - type: object - properties: - methods: - description: The list of payment methods supported by this incoming payment. - type: array - uniqueItems: true - minItems: 0 - items: - anyOf: - - $ref: "#/components/schemas/ilp-payment-method" - required: - - methods - public-incoming-payment: - title: Public Incoming Payment - description: An **incoming payment** resource with public details. - type: object - examples: - - receivedAmount: - value: "0" - assetCode: USD - assetScale: 2 - - authServer: "https://auth.rafiki.money" - properties: - receivedAmount: - $ref: ./schemas.yaml#/components/schemas/amount - authServer: - type: string - format: uri - description: The URL of the authorization server endpoint for getting grants and access tokens for this wallet address. - required: - - authServer - unresolvedProperites: false - outgoing-payment: - title: Outgoing Payment - description: "An **outgoing payment** resource represents a payment that will be, is currently being, or has previously been, sent from the wallet address." - type: object - examples: - - id: "https://ilp.rafiki.money/outgoing-payments/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/aplusvideo/incoming-payments/45d495ad-b763-4882-88d7-aa14d261686e" - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - debitAmount: - value: "2600" - assetCode: USD - assetScale: 2 - sentAmount: - value: "2500" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: APlusVideo subscription - externalRef: "customer: 847458475" - - id: "https://ilp.rafiki.money/outgoing-payments/0cffa5a4-58fd-4cc8-8e01-7145c72bf07c" - walletAddress: "https://ilp.rafiki.money/alice/" - failed: false - receiver: "https://ilp.rafiki.money/shoeshop/2fe92c6f-ef0d-487c-8759-3784eae6bce9" - debitAmount: - value: "7126" - assetCode: USD - assetScale: 2 - sentAmount: - value: "7026" - assetCode: USD - assetScale: 2 - createdAt: "2022-03-12T23:20:50.52Z" - updatedAt: "2022-04-01T10:24:36.11Z" - metadata: - description: Thank you for your purchase at ShoeShop! - externalRef: INV2022-8943756 - additionalProperties: false - properties: - id: - type: string - format: uri - description: The URL identifying the outgoing payment. - readOnly: true - walletAddress: - type: string - format: uri - description: The URL of the wallet address from which this payment is sent. - readOnly: true - quoteId: - type: string - format: uri - description: The URL of the quote defining this payment's amounts. - readOnly: true - failed: - type: boolean - description: Describes whether the payment failed to send its full amount. - default: false - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - description: The URL of the incoming payment that is being paid. - receiveAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The total amount that should be received by the receiver when this outgoing payment has been paid. - debitAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The total amount that should be deducted from the sender's account when this outgoing payment has been paid. - sentAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The total amount that has been sent under this outgoing payment. - metadata: - type: object - description: Additional metadata associated with the outgoing payment. (Optional) - createdAt: - type: string - format: date-time - description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. - required: - - id - - walletAddress - - receiver - - receiveAmount - - debitAmount - - sentAmount - - createdAt - - updatedAt - quote: - title: Quote - description: A **quote** resource represents the quoted amount details with which an Outgoing Payment may be created. - type: object - examples: - - id: "https://ilp.rafiki.money/quotes/ab03296b-0c8b-4776-b94e-7ee27d868d4d" - walletAddress: "https://ilp.rafiki.money/alice/" - receiver: "https://ilp.rafiki.money/shoeshop/incoming-payments/2fe92c6f-ef0d-487c-8759-3784eae6bce9" - receiveAmount: - value: "2500" - assetCode: USD - assetScale: 2 - debitAmount: - value: "2600" - assetCode: USD - assetScale: 2 - sentAmount: - value: "2500" - assetCode: USD - assetScale: 2 - method: ilp - createdAt: "2022-03-12T23:20:50.52Z" - expiresAt: "2022-04-12T23:20:50.52Z" - - id: "https://ilp.rafiki.money/quotes/8c68d3cc-0a0f-4216-98b4-4fa44a6c88cf" - walletAddress: "https://ilp.rafiki.money/alice/" - receiver: "https://ilp.rafiki.money/aplusvideo/incoming-payments/45d495ad-b763-4882-88d7-aa14d261686e" - debitAmount: - value: "7126" - assetCode: USD - assetScale: 2 - sentAmount: - value: "7026" - assetCode: USD - assetScale: 2 - method: ilp - createdAt: "2022-03-12T23:20:50.52Z" - expiresAt: "2022-04-12T23:20:50.52Z" - additionalProperties: false - properties: - id: - type: string - format: uri - description: The URL identifying the quote. - readOnly: true - walletAddress: - type: string - format: uri - description: The URL of the wallet address from which this quote's payment would be sent. - readOnly: true - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - description: The URL of the incoming payment that the quote is created for. - receiveAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: The total amount that should be received by the receiver when the corresponding outgoing payment has been paid. - debitAmount: - $ref: ./schemas.yaml#/components/schemas/amount - description: "The total amount that should be deducted from the sender's account when the corresponding outgoing payment has been paid. " - method: - $ref: "#/components/schemas/payment-method" - expiresAt: - type: string - description: The date and time when the calculated `debitAmount` is no longer valid. - readOnly: true - createdAt: - type: string - format: date-time - description: The date and time when the quote was created. - required: - - id - - walletAddress - - receiver - - receiveAmount - - debitAmount - - createdAt - - method - page-info: - description: "" - type: object - examples: - - startCursor: 241de237-f989-42be-926d-c0c1fca57708 - endCursor: 315581f8-9967-45a0-9cd3-87b60b6d6414 - hasNextPage: true - hasPreviousPage: true - properties: - startCursor: - type: string - minLength: 1 - description: Cursor corresponding to the first element in the result array. - endCursor: - type: string - minLength: 1 - description: Cursor corresponding to the last element in the result array. - hasNextPage: - type: boolean - description: Describes whether the data set has further entries. - hasPreviousPage: - type: boolean - description: Describes whether the data set has previous entries. - required: - - hasNextPage - - hasPreviousPage - additionalProperties: false - payment-method: - type: string - enum: - - ilp - ilp-payment-method: - type: object - additionalProperties: false - properties: - type: - type: string - enum: - - ilp - ilpAddress: - type: string - maxLength: 1023 - pattern: "^(g|private|example|peer|self|test[1-3]?|local)([.][a-zA-Z0-9_~-]+)+$" - description: The ILP address to use when establishing a STREAM connection. - sharedSecret: - type: string - pattern: "^[a-zA-Z0-9-_]+$" - description: The base64 url-encoded shared secret to use when establishing a STREAM connection. - required: - - type - - ilpAddress - - sharedSecret - examples: - - type: string - ilpAddress: string - sharedSecret: string - securitySchemes: - GNAP: - name: Authorization - type: apiKey - in: header - description: |- - The API uses the Grant Negotiation and Authorization Protocol for authorization. An access token must be acquired from an authorization server before accessing the API and then provided in the request headers using the prefix `GNAP`. - - All requests must also be signed using a client key over some select headers and a digest of the request body. - responses: - "401": - description: Authorization required - headers: - WWW-Authenticate: - schema: - type: string - description: The address of the authorization server for grant requests in the format `GNAP as_uri=` - "403": - description: Forbidden - parameters: - cursor: - schema: - type: string - minLength: 1 - name: cursor - in: query - description: The cursor key to list from. - first: - schema: - type: integer - minimum: 1 - maximum: 100 - name: first - in: query - description: The number of items to return after the cursor. - last: - schema: - type: integer - minimum: 1 - maximum: 100 - name: last - in: query - description: The number of items to return before the cursor. - id: - name: id - in: path - schema: - type: string - description: Sub-resource identifier - required: true - wallet-address: - name: wallet-address - in: query - schema: - type: string - description: "URL of a wallet address hosted by a Rafiki instance." - required: true - signature: - name: Signature - in: header - schema: - type: string - example: "Signature: sig1=:EWJgAONk3D6542Scj8g51rYeMHw96cH2XiCMxcyL511wyemGcw==:" - description: 'The signature generated based on the Signature-Input, using the signing algorithm specified in the "alg" field of the JWK.' - required: true - signature-input: - name: Signature-Input - in: header - schema: - type: string - example: 'Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-rsa"' - description: 'The Signature-Input field is a Dictionary structured field containing the metadata for one or more message signatures generated from components within the HTTP message. Each member describes a single message signature. The member''s key is the label that uniquely identifies the message signature within the context of the HTTP message. The member''s value is the serialization of the covered components Inner List plus all signature metadata parameters identified by the label. The following components MUST be included: - "@method" - "@target-uri" - "authorization". When the message contains a request body, the covered components MUST also include the following: - "content-digest" The keyid parameter of the signature MUST be set to the kid value of the JWK. See [ietf-httpbis-message-signatures](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-4.1) for more details.' - required: true - optional-signature: - name: Signature - in: header - schema: - type: string - example: "Signature: sig1=:EWJgAONk3D6542Scj8g51rYeMHw96cH2XiCMxcyL511wyemGcw==:" - description: 'The signature generated based on the Signature-Input, using the signing algorithm specified in the "alg" field of the JWK.' - optional-signature-input: - name: Signature-Input - in: header - schema: - type: string - example: 'Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-rsa"' - description: 'The Signature-Input field is a Dictionary structured field containing the metadata for one or more message signatures generated from components within the HTTP message. Each member describes a single message signature. The member''s key is the label that uniquely identifies the message signature within the context of the HTTP message. The member''s value is the serialization of the covered components Inner List plus all signature metadata parameters identified by the label. The following components MUST be included: - "@method" - "@target-uri" - "authorization". When the message contains a request body, the covered components MUST also include the following: - "content-digest" The keyid parameter of the signature MUST be set to the kid value of the JWK. See [ietf-httpbis-message-signatures](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-4.1) for more details.' -security: - - GNAP: [] diff --git a/openapi-definitions/schemas.yaml b/openapi-definitions/schemas.yaml deleted file mode 100644 index c08ff83..0000000 --- a/openapi-definitions/schemas.yaml +++ /dev/null @@ -1,54 +0,0 @@ -openapi: 3.1.0 -info: - title: Open Payments - Shared schemas - version: '1.0' - license: - name: Apache 2.0 - identifier: Apache-2.0 - summary: Open Payments - Shared schemas - description: 'Shared schemas used across Open Payments APIs' - contact: - email: tech@interledger.org -components: - schemas: - amount: - title: amount - type: object - properties: - value: - type: string - format: uint64 - description: 'The value is an unsigned 64-bit integer amount, represented as a string.' - assetCode: - $ref: '#/components/schemas/assetCode' - assetScale: - $ref: '#/components/schemas/assetScale' - required: - - value - - assetCode - - assetScale - assetCode: - title: Asset code - type: string - description: The assetCode is a code that indicates the underlying asset. This SHOULD be an ISO4217 currency code. - assetScale: - title: Asset scale - type: integer - minimum: 0 - maximum: 255 - description: The scale of amounts denoted in the corresponding asset code. - receiver: - title: Receiver - type: string - description: The URL of the incoming payment that is being paid. - format: uri - pattern: '^(https|http)://(.+)/incoming-payments/(.+)$' - examples: - - 'https://ilp.rafiki.money/incoming-payments/08394f02-7b7b-45e2-b645-51d04e7c330c' - - 'http://ilp.rafiki.money/incoming-payments/08394f02-7b7b-45e2-b645-51d04e7c330c' - - 'https://ilp.rafiki.money/incoming-payments/1' - walletAddress: - title: Wallet Address - type: string - description: 'URL of a wallet address hosted by a Rafiki instance.' - format: uri diff --git a/openapi-definitions/wallet-address-server.yaml b/openapi-definitions/wallet-address-server.yaml deleted file mode 100644 index 706c20f..0000000 --- a/openapi-definitions/wallet-address-server.yaml +++ /dev/null @@ -1,203 +0,0 @@ -# additionalProperties: true commented out on wallet address because it creats an erroneous -# property on the generated struct. Note that true is the default value so its not necessary to declare. -openapi: 3.1.0 -info: - title: Wallet Address API - version: "1.4" - license: - name: Apache 2.0 - identifier: Apache-2.0 - description: |- - The Wallet Address API is a simple REST API to get basic details about a wallet address. - contact: - email: tech@interledger.org -servers: - - url: "https://rafiki.money/alice" - description: "Server for Alice's wallet address" - - url: "https://rafiki.money/bob" - description: "Server for Bob's wallet address" -tags: - - name: wallet-address - description: wallet address operations -paths: - /: - get: - summary: Get a Wallet Address - tags: - - wallet-address - responses: - "200": - description: Wallet Address Found - content: - application/json: - schema: - $ref: "#/components/schemas/wallet-address" - examples: - Get wallet address for $rafiki.money/alice: - value: - id: "https://rafiki.money/alice" - publicName: Alice - assetCode: USD - assetScale: 2 - authServer: "https://rafiki.money/auth" - resourceServer: "https://rafiki.money/op" - "404": - description: Wallet Address Not Found - operationId: get-wallet-address - description: |- - Retrieve the public information of the Wallet Address. - - This end-point should be open to anonymous requests as it allows clients to verify a Wallet Address URL and get the basic information required to construct new transactions and discover the grant request URL. - - The content should be slow changing and cacheable for long periods. Servers SHOULD use cache control headers. - security: [] - x-internal: false - /jwks.json: - get: - summary: Get the keys bound to a Wallet Address - tags: - - wallet-address - responses: - "200": - description: JWKS Document Found - content: - application/json: - schema: - $ref: "#/components/schemas/json-web-key-set" - examples: {} - "404": - description: JWKS Document Not Found - operationId: get-wallet-address-keys - description: Retrieve the public keys of the Wallet Address. - security: [] - /did.json: - get: - summary: Get the DID Document for this wallet - tags: - - wallet-address - responses: - "200": - description: DID Document Found - content: - application/json: - schema: - $ref: "#/components/schemas/did-document" - "500": - description: DID Document not yet implemented - operationId: get-wallet-address-did-document - description: Retrieve the DID Document of the Wallet Address. - security: [] -components: - schemas: - wallet-address: - title: Wallet Address - type: object - description: A **wallet address** resource is the root of the API and contains the public details of the financial account represented by the Wallet Address that is also the service endpoint URL. - # additionalProperties: true - examples: - - id: "https://rafiki.money/alice" - publicName: Alice - assetCode: USD - assetScale: 2 - authServer: "https://rafiki.money/auth" - resourceServer: "https://rafiki.money/op" - properties: - id: - type: string - format: uri - description: The URL identifying the wallet address. - readOnly: true - publicName: - type: string - description: A public name for the account. This should be set by the account holder with their provider to provide a hint to counterparties as to the identity of the account holder. - readOnly: true - assetCode: - $ref: ./schemas.yaml#/components/schemas/assetCode - assetScale: - $ref: ./schemas.yaml#/components/schemas/assetScale - authServer: - type: string - format: uri - description: The URL of the authorization server endpoint for getting grants and access tokens for this wallet address. - readOnly: true - resourceServer: - type: string - format: uri - description: The URL of the resource server endpoint for performing Open Payments with this wallet address. - readOnly: true - required: - - id - - assetCode - - assetScale - - authServer - - resourceServer - json-web-key-set: - title: JSON Web Key Set document - type: object - description: "A JSON Web Key Set document according to [rfc7517](https://datatracker.ietf.org/doc/html/rfc7517) listing the keys associated with this wallet address. These keys are used to sign requests made by this wallet address." - additionalProperties: false - properties: - keys: - type: array - items: - $ref: "#/components/schemas/json-web-key" - readOnly: true - required: - - keys - examples: - - keys: - - kid: key-1 - alg: EdDSA - use: sig - kty: OKP - crv: Ed25519 - x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo - json-web-key: - type: object - properties: - kid: - type: string - alg: - type: string - description: "The cryptographic algorithm family used with the key. The only allowed value is `EdDSA`. " - enum: - - EdDSA - use: - type: string - enum: - - sig - kty: - type: string - enum: - - OKP - crv: - type: string - enum: - - Ed25519 - x: - type: string - pattern: "^[a-zA-Z0-9-_]+$" - description: The base64 url-encoded public key. - required: - - kid - - alg - - kty - - crv - - x - title: Ed25519 Public Key - description: A JWK representation of an Ed25519 Public Key - examples: - - kid: key-1 - use: sig - kty: OKP - crv: Ed25519 - x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo - - kid: "2022-09-02" - use: sig - kty: OKP - crv: Ed25519 - x: oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI - did-document: - type: object - title: DID Document - description: A DID Document using JSON encoding diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..6efce35 --- /dev/null +++ b/release.toml @@ -0,0 +1,19 @@ +allow-branch = ["main", "release/*"] +consolidate-commits = true +sign-commit = false +sign-tag = false +tag-prefix = "v" +push = true + +[workspace] + +[registry] +index = "https://github.com/rust-lang/crates.io-index" + +[package] +publish = true +tag-name = "v{{version}}" +pre-release-replacements = [ + { file = "README.md", search = "open-payments = \"[0-9]+\.[0-9]+\.[0-9]+\"", replace = "open-payments = \"{{version}}\"", exactly = 2 }, +] + diff --git a/src/client/api.rs b/src/client/api.rs index f6d3f2d..7aa2bf2 100644 --- a/src/client/api.rs +++ b/src/client/api.rs @@ -237,47 +237,47 @@ pub mod unauthenticated { } pub trait AuthenticatedResources { - fn quotes(&self) -> authenticated::QuoteResource; - fn incoming_payments(&self) -> authenticated::IncomingPaymentResource; - fn outgoing_payments(&self) -> authenticated::OutgoingPaymentResource; - fn grant(&self) -> authenticated::Grant; - fn token(&self) -> authenticated::Token; + fn quotes(&self) -> authenticated::QuoteResource<'_>; + fn incoming_payments(&self) -> authenticated::IncomingPaymentResource<'_>; + fn outgoing_payments(&self) -> authenticated::OutgoingPaymentResource<'_>; + fn grant(&self) -> authenticated::Grant<'_>; + fn token(&self) -> authenticated::Token<'_>; } /// Extension trait for any client (authenticated or not) pub trait UnauthenticatedResources: BaseClient + Sized { - fn wallet_address(&self) -> unauthenticated::WalletAddressResource; - fn public_incoming_payments(&self) -> unauthenticated::IncomingPaymentResource; + fn wallet_address(&self) -> unauthenticated::WalletAddressResource<'_, Self>; + fn public_incoming_payments(&self) -> unauthenticated::IncomingPaymentResource<'_, Self>; } impl AuthenticatedResources for AuthenticatedOpenPaymentsClient { - fn quotes(&self) -> authenticated::QuoteResource { + fn quotes(&self) -> authenticated::QuoteResource<'_> { authenticated::QuoteResource::new(self) } - fn incoming_payments(&self) -> authenticated::IncomingPaymentResource { + fn incoming_payments(&self) -> authenticated::IncomingPaymentResource<'_> { authenticated::IncomingPaymentResource::new(self) } - fn outgoing_payments(&self) -> authenticated::OutgoingPaymentResource { + fn outgoing_payments(&self) -> authenticated::OutgoingPaymentResource<'_> { authenticated::OutgoingPaymentResource::new(self) } - fn grant(&self) -> authenticated::Grant { + fn grant(&self) -> authenticated::Grant<'_> { authenticated::Grant::new(self) } - fn token(&self) -> authenticated::Token { + fn token(&self) -> authenticated::Token<'_> { authenticated::Token::new(self) } } impl UnauthenticatedResources for C { - fn wallet_address(&self) -> unauthenticated::WalletAddressResource { + fn wallet_address(&self) -> unauthenticated::WalletAddressResource<'_, Self> { unauthenticated::WalletAddressResource::new(self) } - fn public_incoming_payments(&self) -> unauthenticated::IncomingPaymentResource { + fn public_incoming_payments(&self) -> unauthenticated::IncomingPaymentResource<'_, Self> { unauthenticated::IncomingPaymentResource::new(self) } } diff --git a/src/http_signature/jwk.rs b/src/http_signature/jwk.rs index 373ae51..6b05d10 100644 --- a/src/http_signature/jwk.rs +++ b/src/http_signature/jwk.rs @@ -73,7 +73,7 @@ impl Jwk { "kid": key_id, "x": x }); - let jwks = json!({ "keys": [ jwk ] }); + let jwks = json!({ "keys": [jwk] }); jwks.to_string() } diff --git a/src/http_signature/signatures.rs b/src/http_signature/signatures.rs index e7c548a..1a741eb 100644 --- a/src/http_signature/signatures.rs +++ b/src/http_signature/signatures.rs @@ -16,13 +16,13 @@ pub struct SignOptions<'a> { pub key_id: String, } -impl<'a> SignOptions<'a> { - pub fn new( +impl SignOptions<'_> { + pub fn new<'a>( request: &'a Request>, private_key: &'a SigningKey, key_id: String, - ) -> Self { - Self { + ) -> SignOptions<'a> { + SignOptions { request, private_key, key_id, diff --git a/src/http_signature/utils.rs b/src/http_signature/utils.rs index 89e19f1..5e13ecb 100644 --- a/src/http_signature/utils.rs +++ b/src/http_signature/utils.rs @@ -9,13 +9,15 @@ use std::path::Path; pub fn load_or_generate_key(path: &Path) -> Result { if path.exists() { - let key_str = fs::read_to_string(path)?; - let key_str = key_str.trim(); + let file_content = fs::read_to_string(path)?; + let without_bom = file_content.trim_start_matches('\u{feff}'); + let without_cr = without_bom.replace('\r', ""); + let trimmed = without_cr.trim(); - let key_str = if let Ok(decoded) = STANDARD.decode(key_str) { + let key_str = if let Ok(decoded) = STANDARD.decode(trimmed) { String::from_utf8(decoded)? } else { - key_str.to_string() + trimmed.to_string() }; let pem = parse(&key_str).map_err(|e| HttpSignatureError::Pem(e.to_string()))?; @@ -171,4 +173,19 @@ mod tests { let key2 = load_or_generate_key(&path).unwrap(); assert_eq!(key1.to_bytes(), key2.to_bytes()); } + + #[test] + fn test_invalid_utf8_after_base64_decode() { + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path().join("bad_utf8_b64.pem"); + + // Bytes that are invalid UTF-8 when decoded + let invalid_bytes = vec![0xFF, 0xFF, 0xFF]; + let encoded = STANDARD.encode(&invalid_bytes); + + fs::write(&path, encoded).unwrap(); + + let result = load_or_generate_key(&path); + assert!(matches!(result, Err(HttpSignatureError::Utf8(_)))); + } } diff --git a/src/http_signature/validation.rs b/src/http_signature/validation.rs index 54cd832..0fb3804 100644 --- a/src/http_signature/validation.rs +++ b/src/http_signature/validation.rs @@ -9,13 +9,13 @@ pub struct ValidationOptions<'a> { pub public_key: &'a VerifyingKey, } -impl<'a> ValidationOptions<'a> { - pub fn new( +impl ValidationOptions<'_> { + pub fn new<'a>( request: &'a Request>, headers: &'a HeaderMap, public_key: &'a VerifyingKey, - ) -> Self { - Self { + ) -> ValidationOptions<'a> { + ValidationOptions { request, headers, public_key, @@ -71,29 +71,23 @@ fn create_signature_base_string( parts.join("\n") } -#[allow(clippy::manual_strip)] fn parse_signature_input(signature_input: &str) -> Result<(Vec<&str>, i64, String)> { let mut components = Vec::new(); let mut created = None; let mut keyid = None; // Remove the sig1= prefix if present - let signature_input = if signature_input.starts_with("sig1=") { - &signature_input[5..] - } else { - signature_input - }; + let signature_input = signature_input + .strip_prefix("sig1=") + .unwrap_or(signature_input); for part in signature_input.split(';') { - if part.starts_with('(') { - components = part[1..part.len() - 1] - .split(' ') - .map(|s| s.trim()) - .collect(); - } else if part.starts_with("created=") { - created = Some(part[8..].parse::().unwrap_or(0)); - } else if part.starts_with("keyid=") { - keyid = Some(part[7..].trim_matches('"').to_string()); + if let Some(inner) = part.strip_prefix('(').and_then(|p| p.strip_suffix(')')) { + components = inner.split(' ').map(|s| s.trim()).collect(); + } else if let Some(value) = part.strip_prefix("created=") { + created = value.parse::().ok(); + } else if let Some(value) = part.strip_prefix("keyid=") { + keyid = Some(value.trim_matches('"').to_string()); } } @@ -175,4 +169,130 @@ mod tests { let options = ValidationOptions::new(&request, &headers, &verifying_key); assert!(validate_signature(options).is_ok()); } + + #[test] + fn test_missing_signature_input_header() { + let mut request = Request::new(Some("body".to_string())); + *request.method_mut() = Method::POST; + *request.uri_mut() = Uri::from_static("http://example.com"); + + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = VerifyingKey::from(&signing_key); + + let headers = HeaderMap::new(); + let options = ValidationOptions::new(&request, &headers, &verifying_key); + let err = validate_signature(options).unwrap_err(); + match err { + HttpSignatureError::Validation(msg) => { + assert_eq!(msg, "Missing Signature-Input header"); + } + _ => panic!("unexpected error type"), + } + } + + #[test] + fn test_missing_signature_header() { + let mut request = Request::new(Some("body".to_string())); + *request.method_mut() = Method::POST; + *request.uri_mut() = Uri::from_static("http://example.com"); + + let signing_key = SigningKey::generate(&mut OsRng); + let options = SignOptions::new(&request, &signing_key, "k".to_string()); + let sig = create_signature_headers(options).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("Signature-Input", sig.signature_input.parse().unwrap()); + + let verifying_key = VerifyingKey::from(&signing_key); + let options = ValidationOptions::new(&request, &headers, &verifying_key); + let err = validate_signature(options).unwrap_err(); + match err { + HttpSignatureError::Validation(msg) => { + assert_eq!(msg, "Missing Signature header"); + } + _ => panic!("unexpected error type"), + } + } + + #[test] + fn test_base64_decode_failed() { + let mut request = Request::new(Some("body".to_string())); + *request.method_mut() = Method::POST; + *request.uri_mut() = Uri::from_static("http://example.com"); + + let signing_key = SigningKey::generate(&mut OsRng); + let options = SignOptions::new(&request, &signing_key, "k".to_string()); + let sig = create_signature_headers(options).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("Signature-Input", sig.signature_input.parse().unwrap()); + headers.insert("Signature", "%%%".parse().unwrap()); + + let verifying_key = VerifyingKey::from(&signing_key); + let options = ValidationOptions::new(&request, &headers, &verifying_key); + let err = validate_signature(options).unwrap_err(); + match err { + HttpSignatureError::Validation(msg) => { + assert_eq!(msg, "Base64 decode failed"); + } + _ => panic!("unexpected error type"), + } + } + + #[test] + fn test_invalid_signature_length() { + let mut request = Request::new(Some("body".to_string())); + *request.method_mut() = Method::POST; + *request.uri_mut() = Uri::from_static("http://example.com"); + + let signing_key = SigningKey::generate(&mut OsRng); + let options = SignOptions::new(&request, &signing_key, "k".to_string()); + let sig = create_signature_headers(options).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("Signature-Input", sig.signature_input.parse().unwrap()); + headers.insert("Signature", "aGVsbG8=".parse().unwrap()); // "hello" + + let verifying_key = VerifyingKey::from(&signing_key); + let options = ValidationOptions::new(&request, &headers, &verifying_key); + let err = validate_signature(options).unwrap_err(); + match err { + HttpSignatureError::Validation(msg) => { + assert_eq!(msg, "Invalid signature length"); + } + _ => panic!("unexpected error type"), + } + } + + #[test] + fn test_signature_verification_failed() { + let mut request = Request::new(Some("body".to_string())); + *request.method_mut() = Method::POST; + *request.uri_mut() = Uri::from_static("http://example.com"); + request + .headers_mut() + .insert("Content-Type", "application/json".parse().unwrap()); + + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = VerifyingKey::from(&signing_key); + + let options = SignOptions::new(&request, &signing_key, "k".to_string()); + let sig = create_signature_headers(options).unwrap(); + + // Tamper with request after signing to force verification failure + *request.uri_mut() = Uri::from_static("http://example.com/changed"); + + let mut headers = HeaderMap::new(); + headers.insert("Signature-Input", sig.signature_input.parse().unwrap()); + headers.insert("Signature", sig.signature.parse().unwrap()); + + let options = ValidationOptions::new(&request, &headers, &verifying_key); + let err = validate_signature(options).unwrap_err(); + match err { + HttpSignatureError::Validation(msg) => { + assert_eq!(msg, "Signature verification failed"); + } + _ => panic!("unexpected error type"), + } + } } diff --git a/src/types/mod.rs b/src/types/mod.rs index f973143..ce2ee9d 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -74,9 +74,10 @@ pub mod wallet_address; pub use common::*; pub use auth::{ - AccessItem, AccessToken, AccessTokenRequest, AccessTokenResponse, Continue, ContinueRequest, - ContinueResponse, GrantRequest, GrantResponse, IncomingPaymentAction, InteractRequest, - InteractResponse, LimitsOutgoing, OutgoingPaymentAction, QuoteAction, + AccessItem, AccessToken, AccessTokenRequest, AccessTokenResponse, Continue, + ContinueAccessToken, ContinueRequest, ContinueResponse, GrantRequest, GrantResponse, + IncomingPaymentAction, InteractFinish, InteractRequest, InteractResponse, LimitsOutgoing, + OutgoingPaymentAction, QuoteAction, }; pub use resource::{ diff --git a/src/types/resource.rs b/src/types/resource.rs index ab6dc50..3c96e8a 100644 --- a/src/types/resource.rs +++ b/src/types/resource.rs @@ -17,7 +17,9 @@ pub struct IncomingPayment { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, pub created_at: DateTime, - pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub methods: Option>, } @@ -77,18 +79,13 @@ pub struct OutgoingPayment { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, pub created_at: DateTime, - pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum CreateQuoteRequest { - NoAmountQuote { - #[serde(rename = "walletAddress")] - wallet_address: String, - receiver: Receiver, - method: PaymentMethodType, - }, FixedReceiveAmountQuote { #[serde(rename = "walletAddress")] wallet_address: String, @@ -105,6 +102,12 @@ pub enum CreateQuoteRequest { #[serde(rename = "debitAmount")] debit_amount: Amount, }, + NoAmountQuote { + #[serde(rename = "walletAddress")] + wallet_address: String, + receiver: Receiver, + method: PaymentMethodType, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/tests/client_request_tests.rs b/tests/client_request_tests.rs new file mode 100644 index 0000000..27356dd --- /dev/null +++ b/tests/client_request_tests.rs @@ -0,0 +1,339 @@ +use open_payments::client::{ + AuthenticatedClient, AuthenticatedResources, ClientConfig, UnauthenticatedClient, + UnauthenticatedResources, +}; +use open_payments::types::{ + Amount, CreateIncomingPaymentRequest, CreateOutgoingPaymentRequest, CreateQuoteRequest, + IncomingPayment, PaymentMethodType, PublicIncomingPayment, Receiver, WalletAddress, +}; +use tempfile::tempdir; +use url::Url; +use wiremock::matchers::{header, header_exists, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn dummy_config(base: &str) -> ClientConfig { + ClientConfig { + key_id: "test-key".into(), + private_key_path: std::path::PathBuf::from("tests/private.key"), + jwks_path: None, + wallet_address_url: format!("{base}/alice"), + } +} + +#[tokio::test] +async fn unauthenticated_wallet_address_get_builds_request() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + let wallet_url = base.join("alice").unwrap().to_string(); + let auth_url = base.join("auth").unwrap().to_string(); + let wallet = WalletAddress { + id: wallet_url.clone(), + public_name: None, + asset_code: "EUR".into(), + asset_scale: 2, + auth_server: auth_url, + resource_server: server.uri(), + }; + + Mock::given(method("GET")) + .and(path(base.join("alice").unwrap().path())) + .respond_with(ResponseTemplate::new(200).set_body_json(&wallet)) + .mount(&server) + .await; + + let client = UnauthenticatedClient::new(); + let got = client.wallet_address().get(&wallet_url).await.unwrap(); + assert_eq!(got, wallet); +} + +#[tokio::test] +async fn authenticated_incoming_payment_create_sets_headers_and_body() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + let response_payment = serde_json::json!({ + "id": base.join("incoming-payments/123").unwrap().to_string(), + "walletAddress": base.join("alice").unwrap().to_string(), + "completed": false, + "receivedAmount": {"value": "0", "assetCode": "EUR", "assetScale": 2}, + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + }); + + Mock::given(method("POST")) + .and(path(base.join("incoming-payments").unwrap().path())) + .and(header("content-type", "application/json")) + .and(header_exists("Signature")) + .and(header_exists("Signature-Input")) + .and(header_exists("Content-Digest")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(response_payment.to_string(), "application/json"), + ) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + let req = CreateIncomingPaymentRequest { + wallet_address: base.join("alice").unwrap().to_string(), + incoming_amount: Some(Amount { + value: "100".into(), + asset_code: "EUR".into(), + asset_scale: 2, + }), + expires_at: None, + metadata: None, + }; + + let _ = client + .incoming_payments() + .create(&server.uri(), &req, None) + .await + .unwrap(); +} + +#[tokio::test] +async fn authenticated_quote_create_and_get() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + let created_quote = serde_json::json!({ + "id": base.join("quotes/q1").unwrap().to_string(), + "walletAddress": base.join("alice").unwrap().to_string(), + "receiver": base.join("incoming-payments/123").unwrap().to_string(), + "receiveAmount": {"value": "10", "assetCode": "EUR", "assetScale": 2}, + "debitAmount": {"value": "110", "assetCode": "EUR", "assetScale": 2}, + "method": "ilp", + "createdAt": "2025-01-01T00:00:00Z" + }); + Mock::given(method("POST")) + .and(path(base.join("quotes").unwrap().path())) + .respond_with( + ResponseTemplate::new(200).set_body_raw(created_quote.to_string(), "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path(base.join("quotes/q1").unwrap().path())) + .respond_with( + ResponseTemplate::new(200).set_body_raw(created_quote.to_string(), "application/json"), + ) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + let req = CreateQuoteRequest::FixedReceiveAmountQuote { + wallet_address: base.join("alice").unwrap().to_string(), + receiver: Receiver(base.join("incoming-payments/123").unwrap().to_string()), + method: PaymentMethodType::Ilp, + receive_amount: Amount { + value: "10".into(), + asset_code: "EUR".into(), + asset_scale: 2, + }, + }; + let q = client + .quotes() + .create(&server.uri(), &req, Some("tok")) + .await + .unwrap(); + assert_eq!(q.id, base.join("quotes/q1").unwrap().as_ref()); + + let q2 = client + .quotes() + .get(base.join("quotes/q1").unwrap().as_ref(), Some("tok")) + .await + .unwrap(); + assert_eq!(q2, q); +} + +#[tokio::test] +async fn authenticated_outgoing_payment_create_from_quote() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + let created_payment = serde_json::json!({ + "id": base.join("outgoing-payments/op1").unwrap().to_string(), + "walletAddress": base.join("alice").unwrap().to_string(), + "quoteId": base.join("quotes/q1").unwrap().to_string(), + "failed": false, + "receiver": base.join("incoming-payments/123").unwrap().to_string(), + "receiveAmount": {"value": "10", "assetCode": "EUR", "assetScale": 2}, + "debitAmount": {"value": "110", "assetCode": "EUR", "assetScale": 2}, + "sentAmount": {"value": "0", "assetCode": "EUR", "assetScale": 2}, + "grantSpentDebitAmount": {"value": "0", "assetCode": "EUR", "assetScale": 2}, + "grantSpentReceiveAmount": {"value": "0", "assetCode": "EUR", "assetScale": 2}, + "createdAt": "2025-01-01T00:00:00Z" + }); + Mock::given(method("POST")) + .and(path(base.join("outgoing-payments").unwrap().path())) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(created_payment.to_string(), "application/json"), + ) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + let req = CreateOutgoingPaymentRequest::FromQuote { + wallet_address: base.join("alice").unwrap().to_string(), + quote_id: base.join("quotes/q1").unwrap().to_string(), + metadata: None, + }; + let p = client + .outgoing_payments() + .create(&server.uri(), &req, Some("tok")) + .await + .unwrap(); + assert_eq!( + p.id, + base.join("outgoing-payments/op1").unwrap().to_string() + ); +} + +#[tokio::test] +async fn error_propagates_http_status_and_message() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + Mock::given(method("GET")) + .and(path(base.join("public-payment").unwrap().path())) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = UnauthenticatedClient::new(); + let res: Result = client + .public_incoming_payments() + .get(base.join("public-payment").unwrap().as_ref()) + .await; + assert!(res.is_err()); +} + +#[tokio::test] +async fn error_includes_status_code_and_reason() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + Mock::given(method("GET")) + .and(path(base.join("missing").unwrap().path())) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = UnauthenticatedClient::new(); + let res: Result = client + .public_incoming_payments() + .get(base.join("missing").unwrap().as_ref()) + .await; + let err = res.expect_err("expected error"); + assert_eq!(err.description, "HTTP request failed"); + assert_eq!(err.code, Some(404)); + assert_eq!(err.status.as_deref(), Some("Not Found")); +} + +#[tokio::test] +async fn json_decode_error_maps_to_client_error_without_status() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + // Return invalid JSON with 200 OK + Mock::given(method("GET")) + .and(path(base.join("alice").unwrap().path())) + .respond_with(ResponseTemplate::new(200).set_body_string("not-json")) + .mount(&server) + .await; + + let client = UnauthenticatedClient::new(); + let res = client + .wallet_address() + .get(base.join("alice").unwrap().as_ref()) + .await; + let err = res.expect_err("expected error"); + assert!(err.description.starts_with("HTTP error:")); + assert!(err.code.is_none()); + assert!(err.status.is_none()); +} + +#[tokio::test] +async fn header_parse_error_with_invalid_token() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + // Use an invalid header value (contains newline) to force parse failure + let res: Result = client + .incoming_payments() + .get( + base.join("incoming-payments/p1").unwrap().as_ref(), + Some("bad\ntoken"), + ) + .await; + let err = res.expect_err("expected error"); + assert!(err.description.starts_with("Header parse error:")); +} + +#[tokio::test] +async fn revoke_token_204_no_content_succeeds() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + Mock::given(method("DELETE")) + .and(path(base.join("token/revoke").unwrap().path())) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + let res = client + .token() + .revoke(base.join("token/revoke").unwrap().as_ref(), Some("token")) + .await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn cancel_grant_204_no_content_succeeds() { + let server = MockServer::start().await; + + let base = Url::parse(&server.uri()).unwrap(); + Mock::given(method("DELETE")) + .and(path(base.join("continue/123").unwrap().path())) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let mut config = dummy_config(&server.uri()); + config.private_key_path = tmp.path().join("private.key"); + let client = AuthenticatedClient::new(config).unwrap(); + + let res = client + .grant() + .cancel(base.join("continue/123").unwrap().as_ref(), Some("token")) + .await; + assert!(res.is_ok()); +} diff --git a/tests/integration/.env.example b/tests/integration/.env.example index f144fce..fb461fe 100644 --- a/tests/integration/.env.example +++ b/tests/integration/.env.example @@ -1,4 +1,5 @@ -OPEN_PAYMENTS_SERVER_URL= OPEN_PAYMENTS_WALLET_ADDRESS= OPEN_PAYMENTS_KEY_ID= -OPEN_PAYMENTS_PRIVATE_KEY_PATH= \ No newline at end of file +OPEN_PAYMENTS_PRIVATE_KEY_PATH= +TEST_WALLET_EMAIL= +TEST_WALLET_PASSWORD= \ No newline at end of file diff --git a/tests/integration/common.rs b/tests/integration/common.rs index a12a80a..72d3663 100644 --- a/tests/integration/common.rs +++ b/tests/integration/common.rs @@ -1,6 +1,7 @@ use open_payments::client::ClientConfig; use open_payments::client::{AuthenticatedClient, UnauthenticatedClient}; use open_payments::client::{OpClientError, Result}; +use open_payments::utils; use std::env; pub struct TestSetup { @@ -8,6 +9,8 @@ pub struct TestSetup { pub unauth_client: UnauthenticatedClient, pub resource_server_url: String, pub wallet_address: String, + pub test_wallet_email: Option, + pub test_wallet_password: Option, } impl TestSetup { @@ -16,9 +19,6 @@ impl TestSetup { OpClientError::other(".env file not found in tests/integration directory".to_string()) })?; - let resource_server_url = env::var("OPEN_PAYMENTS_SERVER_URL").map_err(|_| { - OpClientError::other("OPEN_PAYMENTS_SERVER_URL not set in .env file".to_string()) - })?; let wallet_address = env::var("OPEN_PAYMENTS_WALLET_ADDRESS").map_err(|_| { OpClientError::other("OPEN_PAYMENTS_WALLET_ADDRESS not set in .env file".to_string()) })?; @@ -28,6 +28,9 @@ impl TestSetup { let private_key_path = env::var("OPEN_PAYMENTS_PRIVATE_KEY_PATH").map_err(|_| { OpClientError::other("OPEN_PAYMENTS_PRIVATE_KEY_PATH not set in .env file".to_string()) })?; + let test_wallet_email = env::var("TEST_WALLET_EMAIL").ok(); + let test_wallet_password = env::var("TEST_WALLET_PASSWORD").ok(); + let resource_server_url = utils::get_resource_server_url(&wallet_address)?; let config = ClientConfig { key_id, @@ -44,6 +47,8 @@ impl TestSetup { unauth_client, resource_server_url, wallet_address, + test_wallet_email, + test_wallet_password, }; Ok(test_setup) diff --git a/tests/integration/grant.rs b/tests/integration/grant.rs index 20ed08a..f15fd35 100644 --- a/tests/integration/grant.rs +++ b/tests/integration/grant.rs @@ -43,7 +43,6 @@ async fn test_grant_flows() { access_token, continue_, } => { - println!("Received access token: {}", access_token.value); assert!(!access_token.value.is_empty()); assert!(!access_token.manage.is_empty()); assert!(!continue_.uri.is_empty()); diff --git a/tests/integration/incoming_payment.rs b/tests/integration/incoming_payment.rs index 5688cbc..569a281 100644 --- a/tests/integration/incoming_payment.rs +++ b/tests/integration/incoming_payment.rs @@ -74,7 +74,6 @@ async fn test_incoming_payment_flows() { .await .expect("Failed to create incoming payment"); - println!("Created incoming payment: {}", incoming_payment.id); assert_eq!(incoming_payment.wallet_address, test_setup.wallet_address); assert_eq!( incoming_payment.incoming_amount.as_ref().unwrap().value, diff --git a/tests/integration/outgoing_payment.rs b/tests/integration/outgoing_payment.rs index 79212c7..346f778 100644 --- a/tests/integration/outgoing_payment.rs +++ b/tests/integration/outgoing_payment.rs @@ -1 +1,365 @@ -//TODO To test after figuring out how to pass the interaction +use crate::integration::common::TestSetup; +use open_payments::client::{AuthenticatedResources, UnauthenticatedResources}; +use open_payments::types::{ + AccessItem, AccessTokenRequest, Amount, ContinueResponse, CreateOutgoingPaymentRequest, + CreateQuoteRequest, GrantRequest, GrantResponse, IncomingPaymentAction, IncomingPaymentRequest, + InteractFinish, InteractRequest, OutgoingPaymentAction, PaymentMethodType, QuoteAction, + Receiver, +}; +use thirtyfour::prelude::*; + +#[tokio::test] +async fn test_outgoing_payment_flow_with_interaction() { + async fn webdriver_ready(base_url: &str) -> bool { + let status_url = format!("{}/status", base_url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .ok(); + if let Some(c) = client { + if let Ok(resp) = c.get(&status_url).send().await { + return resp.status().is_success(); + } + } + false + } + // Determine WebDriver URL and skip if not reachable + let webdriver_url = + std::env::var("WEBDRIVER_URL").unwrap_or_else(|_| "http://localhost:4444".into()); + if !webdriver_ready(&webdriver_url).await { + eprintln!( + "Skipping test_outgoing_payment_flow_with_interaction: WebDriver not available at {webdriver_url}" + ); + return; + } + // Skip test if integration .env is missing + let test_setup = match TestSetup::new().await { + Ok(v) => v, + Err(err) => { + eprintln!("Skipping test_outgoing_payment_flow_with_interaction: {err}"); + return; + } + }; + + let wallet_address = test_setup + .auth_client + .wallet_address() + .get(&test_setup.wallet_address) + .await + .expect("Failed to get wallet address"); + + // Create an incoming payment (receiver) using a non-interactive grant + let ip_grant_request = GrantRequest::new( + AccessTokenRequest { + access: vec![AccessItem::IncomingPayment { + actions: vec![IncomingPaymentAction::Create, IncomingPaymentAction::Read], + identifier: None, + }], + }, + None, + ); + let ip_grant = test_setup + .auth_client + .grant() + .request(&wallet_address.auth_server, &ip_grant_request) + .await + .expect("Failed to request incoming payment grant"); + + let ip_access_token = match ip_grant { + GrantResponse::WithToken { access_token, .. } => access_token.value, + GrantResponse::WithInteraction { .. } => { + panic!("Unexpected interaction for incoming payment creation") + } + }; + + let incoming_req = IncomingPaymentRequest { + wallet_address: wallet_address.id.clone(), + incoming_amount: Some(Amount { + value: "1000".into(), + asset_code: wallet_address.asset_code.clone(), + asset_scale: wallet_address.asset_scale, + }), + metadata: None, + expires_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)), + }; + + let incoming_payment = test_setup + .auth_client + .incoming_payments() + .create( + &test_setup.resource_server_url, + &incoming_req, + Some(&ip_access_token), + ) + .await + .expect("Failed to create incoming payment"); + + // Helper to perform the browser interaction and return the outgoing payment token + async fn perform_interaction_and_continue( + driver_url: &str, + redirect: &str, + consent_selector: &str, + continue_uri: &str, + continue_token: &str, + client: &open_payments::client::AuthenticatedClient, + test_setup: &TestSetup, + ) -> Option { + let caps = DesiredCapabilities::chrome(); + let driver = WebDriver::new(driver_url, caps) + .await + .expect("Start webdriver"); + driver + .set_page_load_timeout(std::time::Duration::from_secs(20)) + .await + .ok(); + driver + .set_implicit_wait_timeout(std::time::Duration::from_secs(10)) + .await + .ok(); + driver.goto(redirect).await.expect("Navigate to redirect"); + if let Ok(url_now) = driver.current_url().await { + println!("Navigated to: {url_now}"); + } + + // If we're on the wallet login, attempt to log in using env creds + if let Ok(url_now) = driver.current_url().await { + let on_login = url_now.as_str().contains("/auth/login"); + if on_login { + let email = test_setup.test_wallet_email.clone(); + let password = test_setup.test_wallet_password.clone(); + if let (Some(email), Some(password)) = (email, password) { + let email_input = { + let mut found = None; + for _ in 0..100 { + if let Ok(e) = driver + .find(By::Css("input[type='email']".to_string())) + .await + { + found = Some(e); + break; + } + if let Ok(e) = driver + .find(By::Css("input[name='email']".to_string())) + .await + { + found = Some(e); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + found.expect("Email input not found") + }; + email_input.clear().await.ok(); + email_input.send_keys(email).await.expect("Type email"); + + let password_input = { + let mut found = None; + for _ in 0..100 { + if let Ok(e) = driver + .find(By::Css("input[type='password']".to_string())) + .await + { + found = Some(e); + break; + } + if let Ok(e) = driver + .find(By::Css("input[name='password']".to_string())) + .await + { + found = Some(e); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + found.expect("Password input not found") + }; + password_input.clear().await.ok(); + password_input + .send_keys(password) + .await + .expect("Type password"); + + let submit_btn = { + let mut found = None; + for _ in 0..100 { + if let Ok(b) = driver + .find(By::Css("button[type='submit']".to_string())) + .await + { + found = Some(b); + break; + } + if let Ok(b) = driver.find(By::XPath("//button[normalize-space()='Sign in' or normalize-space()='Log in']".to_string())).await { found = Some(b); break; } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + found.expect("Submit button not found") + }; + submit_btn.click().await.expect("Click submit"); + + // Wait to be redirected to interact page showing Accept + for _ in 0..150 { + if let Ok(src) = driver.source().await { + if src.contains("Accept") { + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } else { + eprintln!("Skipping: TEST_WALLET_EMAIL or TEST_WALLET_PASSWORD not set; login required for consent page"); + let _ = driver.quit().await; + return None; + } + } + } + + let btn = match driver.find(By::Css(consent_selector.to_string())).await { + Ok(elem) => elem, + Err(_) => driver + .find(By::XPath( + "//*[normalize-space()='Accept' and (self::button or @role='button')]", + )) + .await + .expect("Find consent button by text"), + }; + btn.click().await.expect("Click consent"); + + let mut current_url = String::new(); + for _ in 0..100 { + // ~10s + let url_now = driver.current_url().await.expect("Get current url"); + let url_now_str = url_now.as_str().to_string(); + if url_now_str.contains("interact_ref=") { + current_url = url_now_str; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + let _ = driver.quit().await; + let interact_ref = url::Url::parse(¤t_url) + .ok() + .and_then(|u| { + u.query_pairs() + .find(|(k, _)| k == "interact_ref") + .map(|(_, v)| v.to_string()) + }) + .expect("interact_ref not found in URL"); + let cont = client + .grant() + .continue_grant(continue_uri, &interact_ref, Some(continue_token)) + .await + .expect("Continue grant"); + match cont { + ContinueResponse::WithToken { access_token, .. } => Some(access_token.value), + _ => panic!("Expected token after grant continuation"), + } + } + + // Request grant for quote, then create quote with that token + let quote_grant_req = GrantRequest::new( + AccessTokenRequest { + access: vec![AccessItem::Quote { + actions: vec![QuoteAction::Create, QuoteAction::Read], + }], + }, + None, + ); + + let quote_grant = test_setup + .auth_client + .grant() + .request(&wallet_address.auth_server, "e_grant_req) + .await + .expect("Request quote grant"); + let quote_token = match quote_grant { + GrantResponse::WithToken { access_token, .. } => access_token.value, + GrantResponse::WithInteraction { .. } => { + panic!("Unexpected interaction required for quote grant") + } + }; + + let quote_req = CreateQuoteRequest::FixedReceiveAmountQuote { + wallet_address: wallet_address.id.clone(), + receiver: Receiver(incoming_payment.id.clone()), + method: PaymentMethodType::Ilp, + receive_amount: Amount { + value: "1000".into(), + asset_code: wallet_address.asset_code.clone(), + asset_scale: wallet_address.asset_scale, + }, + }; + let quote = test_setup + .auth_client + .quotes() + .create( + &test_setup.resource_server_url, + "e_req, + Some("e_token), + ) + .await + .expect("Create quote"); + + // Request grant for outgoing payment, then create it + let consent_selector = + std::env::var("CONSENT_SELECTOR").unwrap_or_else(|_| "button[aria-label='accept']".into()); + let op_interact = InteractRequest { + start: vec!["redirect".into()], + finish: Some(InteractFinish { + method: "redirect".into(), + uri: "http://localhost/callback".into(), + nonce: "op-nonce".into(), + }), + }; + let op_grant_req = GrantRequest::new( + AccessTokenRequest { + access: vec![AccessItem::OutgoingPayment { + actions: vec![ + OutgoingPaymentAction::Create, + OutgoingPaymentAction::Read, + OutgoingPaymentAction::List, + ], + identifier: wallet_address.id.clone(), + limits: None, + }], + }, + Some(op_interact), + ); + let op_grant = test_setup + .auth_client + .grant() + .request(&wallet_address.auth_server, &op_grant_req) + .await + .expect("Request outgoing payment grant"); + + let op_token = match op_grant { + GrantResponse::WithInteraction { + interact, + continue_, + } => { + perform_interaction_and_continue( + &webdriver_url, + &interact.redirect, + &consent_selector, + &continue_.uri, + &continue_.access_token.value, + &test_setup.auth_client, + &test_setup, + ) + .await + } + GrantResponse::WithToken { access_token, .. } => Some(access_token.value), + } + .expect("Get outgoing payment token"); + + let req = CreateOutgoingPaymentRequest::FromQuote { + wallet_address: wallet_address.id.clone(), + quote_id: quote.id, + metadata: None, + }; + test_setup + .auth_client + .outgoing_payments() + .create(&test_setup.resource_server_url, &req, Some(&op_token)) + .await + .expect("Failed to create outgoing payment"); +} diff --git a/tests/integration/quote.rs b/tests/integration/quote.rs index e11e385..fdb4524 100644 --- a/tests/integration/quote.rs +++ b/tests/integration/quote.rs @@ -49,7 +49,7 @@ async fn create_incoming_payment(test_setup: &TestSetup, access_token: &str) -> let request = IncomingPaymentRequest { wallet_address: test_setup.wallet_address.clone(), incoming_amount: Some(Amount { - value: "100".to_string(), + value: "1000".to_string(), asset_code: "EUR".to_string(), asset_scale: 2, }), @@ -105,7 +105,7 @@ async fn test_quote_flows() { receiver: Receiver(incoming_payment_url.clone()), method: PaymentMethodType::Ilp, debit_amount: Amount { - value: "100".to_string(), + value: "1000".to_string(), asset_code: "EUR".to_string(), asset_scale: 2, }, @@ -123,7 +123,7 @@ async fn test_quote_flows() { .expect("Failed to create quote"); assert_eq!(quote.wallet_address, test_setup.wallet_address); - assert_eq!(quote.debit_amount.value, "100"); + assert_eq!(quote.debit_amount.value, "1000"); let retrieved_quote = test_setup .auth_client @@ -141,7 +141,7 @@ async fn test_quote_flows() { receiver: Receiver(incoming_payment_url), method: PaymentMethodType::Ilp, receive_amount: Amount { - value: "100".to_string(), + value: "1000".to_string(), asset_code: "EUR".to_string(), asset_scale: 2, }, @@ -159,5 +159,5 @@ async fn test_quote_flows() { .expect("Failed to create quote"); assert_eq!(quote.wallet_address, test_setup.wallet_address); - assert_eq!(quote.receive_amount.value, "100"); + assert_eq!(quote.receive_amount.value, "1000"); } diff --git a/tests/types_roundtrip.rs b/tests/types_roundtrip.rs new file mode 100644 index 0000000..f1fd65f --- /dev/null +++ b/tests/types_roundtrip.rs @@ -0,0 +1,469 @@ +use chrono::{TimeZone, Utc}; +use open_payments::types::*; + +fn serde_roundtrip(value: &T) +where + T: serde::Serialize + for<'de> serde::Deserialize<'de> + PartialEq + std::fmt::Debug, +{ + let s = serde_json::to_string(value).expect("serialize"); + let back: T = serde_json::from_str(&s).expect("deserialize"); + assert_eq!(&back, value); +} + +#[test] +fn amount_roundtrip() { + let v = Amount { + value: "1000".into(), + asset_code: "USD".into(), + asset_scale: 2, + }; + serde_roundtrip(&v); +} + +#[test] +fn wallet_address_roundtrip() { + let v = WalletAddress { + id: "https://ilp.interledger-test.dev/alice".into(), + public_name: Some("Alice Test Wallet".into()), + asset_code: "USD".into(), + asset_scale: 2, + auth_server: "https://auth.interledger-test.dev".into(), + resource_server: "https://ilp.interledger-test.dev".into(), + }; + serde_roundtrip(&v); +} + +#[test] +fn incoming_payment_roundtrip_minimal() { + let v = IncomingPayment { + id: "https://ilp.interledger-test.dev/incoming-payments/123".into(), + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + completed: false, + incoming_amount: None, + received_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + expires_at: None, + metadata: None, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: None, + methods: None, + }; + serde_roundtrip(&v); +} + +#[test] +fn outgoing_payment_roundtrip_minimal() { + let v = OutgoingPayment { + id: "https://ilp.interledger-test.dev/outgoing-payments/abc".into(), + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + quote_id: Some("https://ilp.interledger-test.dev/quotes/q1".into()), + failed: false, + receiver: Receiver("https://ilp.interledger-test.dev/incoming-payments/123".into()), + receive_amount: Amount { + value: "10".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + debit_amount: Amount { + value: "110".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + sent_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + grant_spent_debit_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + grant_spent_receive_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + metadata: None, + created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + updated_at: None, + }; + serde_roundtrip(&v); +} + +#[test] +fn quote_roundtrip() { + let v = Quote { + id: "https://ilp.interledger-test.dev/quotes/q1".into(), + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + receiver: Receiver("https://ilp.interledger-test.dev/incoming-payments/123".into()), + receive_amount: Amount { + value: "10".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + debit_amount: Amount { + value: "110".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + method: PaymentMethodType::Ilp, + expires_at: None, + created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + }; + serde_roundtrip(&v); +} + +#[test] +fn quote_request_untagged_roundtrip_variants() { + let base = CreateQuoteRequest::NoAmountQuote { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + receiver: Receiver("https://ilp.interledger-test.dev/incoming-payments/123".into()), + method: PaymentMethodType::Ilp, + }; + serde_roundtrip(&base); + + let fixed_recv = CreateQuoteRequest::FixedReceiveAmountQuote { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + receiver: Receiver("https://ilp.interledger-test.dev/incoming-payments/123".into()), + method: PaymentMethodType::Ilp, + receive_amount: Amount { + value: "10".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + }; + serde_roundtrip(&fixed_recv); + + let fixed_send = CreateQuoteRequest::FixedSendAmountQuote { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + receiver: Receiver("https://ilp.interledger-test.dev/incoming-payments/123".into()), + method: PaymentMethodType::Ilp, + debit_amount: Amount { + value: "10".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + }; + serde_roundtrip(&fixed_send); +} + +#[test] +fn outgoing_payment_request_untagged_roundtrip_variants() { + let from_quote = CreateOutgoingPaymentRequest::FromQuote { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + quote_id: "https://ilp.interledger-test.dev/quotes/123".into(), + metadata: None, + }; + serde_roundtrip(&from_quote); + + let from_incoming = CreateOutgoingPaymentRequest::FromIncomingPayment { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + incoming_payment_id: "https://ilp.interledger-test.dev/incoming-payments/123".into(), + debit_amount: Amount { + value: "110".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + metadata: None, + }; + serde_roundtrip(&from_incoming); +} + +#[test] +fn access_item_roundtrip_variants() { + let inc = AccessItem::IncomingPayment { + actions: vec![IncomingPaymentAction::Create, IncomingPaymentAction::Read], + identifier: None, + }; + serde_roundtrip(&inc); + + let out = AccessItem::OutgoingPayment { + actions: vec![ + OutgoingPaymentAction::Create, + OutgoingPaymentAction::Read, + OutgoingPaymentAction::List, + ], + identifier: "https://ilp.interledger-test.dev/alice".into(), + limits: None, + }; + serde_roundtrip(&out); + + let quote = AccessItem::Quote { + actions: vec![QuoteAction::Create, QuoteAction::Read], + }; + serde_roundtrip("e); +} + +#[test] +fn actions_roundtrip_all_variants() { + for action in [ + IncomingPaymentAction::Create, + IncomingPaymentAction::Complete, + IncomingPaymentAction::Read, + IncomingPaymentAction::ReadAll, + IncomingPaymentAction::List, + IncomingPaymentAction::ListAll, + ] { + serde_roundtrip(&action); + } + for action in [ + OutgoingPaymentAction::Create, + OutgoingPaymentAction::Read, + OutgoingPaymentAction::ReadAll, + OutgoingPaymentAction::List, + OutgoingPaymentAction::ListAll, + ] { + serde_roundtrip(&action); + } + for action in [QuoteAction::Create, QuoteAction::Read, QuoteAction::ReadAll] { + serde_roundtrip(&action); + } +} + +#[test] +fn grant_and_continue_response_roundtrip_variants() { + let cont = Continue { + access_token: open_payments::types::ContinueAccessToken { + value: "ctok".into(), + }, + uri: "https://auth.interledger-test.dev/continue/abc".into(), + wait: Some(1), + }; + let with_token = GrantResponse::WithToken { + access_token: AccessToken { + value: "av".into(), + manage: "https://auth.interledger-test.dev/manage".into(), + expires_in: Some(3600), + access: None, + }, + continue_: cont.clone(), + }; + serde_roundtrip(&with_token); + + let with_interaction = GrantResponse::WithInteraction { + interact: InteractResponse { + redirect: "https://auth.interledger-test.dev/interact/abc/finish".into(), + finish: "finish-nonce".into(), + }, + continue_: cont.clone(), + }; + serde_roundtrip(&with_interaction); + + let cr_with_token = ContinueResponse::WithToken { + access_token: AccessToken { + value: "av".into(), + manage: "https://auth.interledger-test.dev/manage".into(), + expires_in: Some(3600), + access: None, + }, + continue_: cont.clone(), + }; + serde_roundtrip(&cr_with_token); + let cr_pending = ContinueResponse::Pending { continue_: cont }; + serde_roundtrip(&cr_pending); +} + +#[test] +fn jwk_enums_and_payment_method_roundtrip() { + serde_roundtrip(&PaymentMethodType::Ilp); + let pm = PaymentMethod::Ilp { + ilp_address: "test.bank".into(), + shared_secret: "abc".into(), + }; + serde_roundtrip(&pm); + + serde_roundtrip(&JwkAlgorithm::EdDSA); + serde_roundtrip(&JwkUse::Signature); + serde_roundtrip(&JwkKeyType::OKP); + serde_roundtrip(&JwkCurve::Ed25519); +} + +#[test] +fn receiver_walleturi_interval_roundtrip() { + serde_roundtrip(&Receiver( + "https://ilp.interledger-test.dev/incoming-payments/xyz".into(), + )); + serde_roundtrip(&WalletAddressUri( + "https://ilp.interledger-test.dev/alice".into(), + )); + serde_roundtrip(&Interval("P1D".into())); +} + +#[test] +fn jwk_set_roundtrip() { + let jwk = JsonWebKey { + kid: "kid-1".into(), + alg: JwkAlgorithm::EdDSA, + use_: Some(JwkUse::Signature), + kty: JwkKeyType::OKP, + crv: JwkCurve::Ed25519, + x: "base64url".into(), + }; + let set = JsonWebKeySet { keys: vec![jwk] }; + serde_roundtrip(&set); +} + +#[test] +fn access_token_and_response_roundtrip() { + let tok = AccessToken { + value: "token".into(), + manage: "https://auth.interledger-test.dev/manage".into(), + expires_in: Some(3600), + access: None, + }; + serde_roundtrip(&tok); + let resp = AccessTokenResponse { access_token: tok }; + serde_roundtrip(&resp); +} + +#[test] +fn grant_and_related_requests_roundtrip() { + let at_req = AccessTokenRequest { + access: vec![AccessItem::Quote { + actions: vec![QuoteAction::Create], + }], + }; + serde_roundtrip(&at_req); + + let grant = GrantRequest::new( + at_req, + Some(InteractRequest { + start: vec!["redirect".into()], + finish: Some(InteractFinish { + method: "redirect".into(), + uri: "https://client.example/finish".into(), + nonce: "n".into(), + }), + }), + ); + serde_roundtrip(&grant); + + let ir = InteractResponse { + redirect: "https://auth.interledger-test.dev/interact/abc/finish".into(), + finish: "finish".into(), + }; + serde_roundtrip(&ir); + + let cont_req = ContinueRequest { + interact_ref: Some("ref123".into()), + }; + serde_roundtrip(&cont_req); + + let cont = Continue { + access_token: ContinueAccessToken { + value: "ctok".into(), + }, + uri: "https://auth.interledger-test.dev/continue/abc".into(), + wait: Some(1), + }; + serde_roundtrip(&cont); +} + +#[test] +fn limits_outgoing_roundtrip() { + let limits = LimitsOutgoing { + receiver: Some(Receiver( + "https://ilp.interledger-test.dev/incoming-payments/xyz".into(), + )), + debit_amount: Some(Amount { + value: "200".into(), + asset_code: "USD".into(), + asset_scale: 2, + }), + receive_amount: None, + interval: Some(Interval("P1D".into())), + }; + serde_roundtrip(&limits); +} + +#[test] +fn public_and_create_incoming_payment_roundtrip() { + let pip = PublicIncomingPayment { + received_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + auth_server: "https://auth.interledger-test.dev".into(), + }; + serde_roundtrip(&pip); + + let cip = CreateIncomingPaymentRequest { + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + incoming_amount: Some(Amount { + value: "100".into(), + asset_code: "USD".into(), + asset_scale: 2, + }), + expires_at: None, + metadata: None, + }; + serde_roundtrip(&cip); +} + +#[test] +fn incoming_payment_with_methods_roundtrip() { + let ilp = PaymentMethod::Ilp { + ilp_address: "test.bank".into(), + shared_secret: "s".into(), + }; + let base = IncomingPayment { + id: "https://ilp.interledger-test.dev/incoming-payments/123".into(), + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + completed: false, + incoming_amount: Some(Amount { + value: "10".into(), + asset_code: "USD".into(), + asset_scale: 2, + }), + received_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + expires_at: None, + metadata: None, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: None, + methods: None, + }; + let wrapped = IncomingPaymentWithMethods { + payment: base, + methods: vec![ilp], + }; + serde_roundtrip(&wrapped); +} + +#[test] +fn paginated_response_roundtrip() { + let item = IncomingPayment { + id: "https://ilp.interledger-test.dev/incoming-payments/1".into(), + wallet_address: "https://ilp.interledger-test.dev/alice".into(), + completed: false, + incoming_amount: None, + received_amount: Amount { + value: "0".into(), + asset_code: "USD".into(), + asset_scale: 2, + }, + expires_at: None, + metadata: None, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: None, + methods: None, + }; + let page = PaginatedResponse { + pagination: PageInfo { + start_cursor: Some("s".into()), + end_cursor: Some("e".into()), + has_next_page: false, + has_previous_page: false, + }, + result: vec![item], + }; + serde_roundtrip(&page); +}