From 719625ad3e94d23a0296cd77e2d1ab5ba46f4cc4 Mon Sep 17 00:00:00 2001 From: "Peter.Rojs" Date: Fri, 31 Jul 2026 20:35:33 +0000 Subject: [PATCH 1/2] plan-refund-destination-external-account --- ...-31-refund-destination-external-account.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/plans/2026-07-31-refund-destination-external-account.md diff --git a/docs/plans/2026-07-31-refund-destination-external-account.md b/docs/plans/2026-07-31-refund-destination-external-account.md new file mode 100644 index 000000000..4d783f23a --- /dev/null +++ b/docs/plans/2026-07-31-refund-destination-external-account.md @@ -0,0 +1,256 @@ +# Refund destination as a source external account + +## Context + +PR #736 (Bitcoin L1 payment instruction, merged 2026-07-30) originally added a free-text +`refundAddress` to `RealtimeFundingQuoteSource` and `AccountDestination`. Both were pulled +before merge and deferred to a follow-up. This is that follow-up. + +@jklein24's review, [thread `r3686228352`](https://github.com/lightsparkdev/grid-api/pull/736#discussion_r3686228352): + +> I'm wondering if it might be good to use an external account here for whitelisting and +> compliance reasons. We have a status field on external accounts that lets us approve +> things ahead of time, etc. + +and later in that thread, per the linked Slack discussion: *"frame this as a source external +account… we'll need to validate that these are 1P wallets in some cases."* @peterrojs chose +option (b) — hold the raw field, model it properly in a follow-up. + +The destination-side field is *not* coming back. [Thread `r3670181155`](https://github.com/lightsparkdev/grid-api/pull/736#discussion_r3670181155) +settled that: a failed crypto payout refunds to a Grid-controlled address Grid already knows, +and `AccountDestination` also covers fiat accounts where a refund *address* is meaningless. +@jklein24: *"Why is this inside of the destination object?"* + +## Approach + +Add `refundAccountId` to `RealtimeFundingQuoteSource` — a reference to an existing +`ExternalAccount`, not an address string. The refund target then inherits everything an +external account carries: `status` (`PENDING`/`ACTIVE`/`UNDER_REVIEW`/`INACTIVE`) for +pre-approval, the trust/untrust confirm flow under +`/customers/external-accounts/{id}/trust|untrust`, and beneficiary verification +(`beneficiaryVerificationStatus`) for the 1P-wallet checks Jeremy flagged. A free-text +address bypasses all of it. + +Rationale for the specific shape: + +- **A plain `accountId` string, not a `$ref` to a reference object.** `AccountQuoteSource` + and `AccountDestination` both take a bare `accountId: string`. Matching that keeps the + quote schemas uniform. `transfers/ExternalAccountReference.yaml` exists but is a + request-body *root* schema for `/transfer-in`, not an embedded field idiom. +- **Named `refundAccountId`, not `refundAddress` or `refundExternalAccountId`.** The `-AccountId` + suffix matches `accountId`/`customerId` elsewhere, and the name no longer implies a raw + address. Alternative considered: keep `refundAddress` as a deprecated alias — rejected, + nothing shipped it, so there is nothing to alias. +- **Optional, with a documented fallback chain.** Grid falls back to the customer's internal + account for that asset. When there is neither a refund account nor an internal account to + fall back on — the no-custody passthrough case — quote creation is rejected rather than + accepting a deposit it cannot unwind. That rejection is currently undocumented behavior; + this plan surfaces it as a real `400` code. +- **`BITCOIN_L1` external-account type.** Without it a BTC L1 refund target cannot be + expressed at all. Every other crypto corridor (`BASE_WALLET`, `ETHEREUM_WALLET`, + `PLASMA_WALLET`, `POLYGON_WALLET`, `SOLANA_WALLET`, `SPARK_WALLET`, `TRON_WALLET`) already + has a member, so this is the only new type needed. + +Not doing: any `AccountDestination` field (settled), and no change to how refunds are +*derived* when no refund account is given — "in most cases we can get that from the +transaction" stays true and stays server-side. + +## Relevant Knowledge + +- Bundle conflicts resolve by `make build` — never hand-merge `openapi.yaml` / + `mintlify/openapi.yaml`. +- **Adding a component schema requires a matching `.stainless/stainless.yml` model entry**, + or the `preview` SDK-build check fails. +- `Detect breaking changes` (oasdiff) will flag `request-property-added` / + `response-property-one-of-added` here. Adding an enum value to a shared type propagates to + every member inheriting it: `ExternalAccountType` is referenced through + `BaseExternalAccountInfo`, so the blast radius is every external-account member's + `accountType`. #736 already took the equivalent hit on `PaymentAccountType` (`BITCOIN_L1` + landed there, `common/PaymentAccountType.yaml:30`), so this is a known-shape finding. +- `common/*WalletInfo.yaml` schemas are **not** registered in `.stainless/stainless.yml` + (neither `EthereumWalletInfo` nor `SparkWalletInfo` appears there) — they are hoisted into + the bundle by redocly and consumed through `allOf`, so only the + `*ExternalAccountInfo` wrapper needs a model entry. +- oasdiff against a stale base produces bogus breaking findings — rebase before trusting it. +- Bolt pushes do not always trigger this repo's `pull_request` OpenAPI workflows; verify the + runs are attached to the actual head commit rather than trusting a green rollup. +- Server side for context (no change here): `EntGridOrchestraSettlementInfo` already carries + `refund_address` + `RefundAddressOwner` on the source chain, so a caller-supplied refund + account maps onto an existing column rather than needing new provider state. + +## Changes + +### 1. `openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml` + +- **What**: add an optional `refundAccountId` property. +- **Why**: the one place the refund target genuinely cannot be inferred — an inbound on-chain + deposit whose sender Grid cannot derive. +- **Code sketch**: + ```yaml + refundAccountId: + type: string + description: >- + External account the funds are returned to if an on-chain deposit cannot be + routed to complete the payment. Must be an existing external account belonging + to `customerId` whose `currency` and network match the funding source, and whose + `status` is `ACTIVE`. Only meaningful when `currency` is a crypto asset. + + If omitted, funds are returned to the customer's internal account for that + asset. When the customer has no internal account to fall back on, quote + creation fails with `REFUND_DESTINATION_REQUIRED` rather than accepting a + deposit that cannot be returned. + example: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + ``` + +### 2. `openapi/components/schemas/common/BitcoinWalletInfo.yaml` (new) + +- **What**: the shared BTC L1 address shape, mirroring `EthereumWalletInfo.yaml`. +- **Why**: `common/` holds the address shape so both the external-account member and any + future payment-instruction member reuse it — the established pattern for every other chain. +- **Code sketch**: + ```yaml + type: object + required: + - address + - accountType + properties: + accountType: + type: string + enum: + - BITCOIN_L1 + address: + type: string + description: Bitcoin (L1) on-chain address + example: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq + ``` + +### 3. `openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml` (new) + +- **What**: the external-account oneOf member. +- **Why**: makes a BTC L1 address a first-class, whitelistable external account. +- **Code sketch**: + ```yaml + title: Bitcoin L1 Wallet + allOf: + - $ref: ./BaseExternalAccountInfo.yaml + - $ref: ../common/BitcoinWalletInfo.yaml + ``` + +### 4. `openapi/components/schemas/external_accounts/ExternalAccountType.yaml` + +- **What**: add `BITCOIN_L1` to the enum. +- **Why**: the discriminator value for the new member. + +### 5. `openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml` + +- **What**: add the `$ref` and the `BITCOIN_L1` discriminator mapping entry. +- **Code sketch**: + ```yaml + - $ref: ./BitcoinWalletExternalAccountInfo.yaml + # … + BITCOIN_L1: ./BitcoinWalletExternalAccountInfo.yaml + ``` + +### 6. `openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml` + +- **What**: same two additions on the create side. +- **Why**: the other wallet types reuse their `*ExternalAccountInfo.yaml` for create (no + separate `*CreateInfo` file), so this member follows suit — otherwise the type exists but + cannot be created. + +### 7. `openapi/components/schemas/errors/Error400.yaml` + +- **What**: add `REFUND_DESTINATION_REQUIRED` to the code table and the `enum`. +- **Why**: makes the rejection path documented API surface instead of a surprise. Both the + markdown table and the enum list need the entry — they are maintained in parallel in this + file. +- **Code sketch**: + ```yaml + | REFUND_DESTINATION_REQUIRED | The crypto funding source has no refund destination and the customer has no internal account for that asset; supply `source.refundAccountId` | + # …and in the enum list: + - REFUND_DESTINATION_REQUIRED + ``` + +### 8. `openapi/paths/quotes/quotes.yaml` + +- **What**: extend the existing `realTimeFundingToSparkWallet` request example with + `refundAccountId`, and note the new `400` code in the `400` response description. +- **Why**: the request example is the only place a caller sees a realtime-funding source + populated end-to-end. + +### 9. `.stainless/stainless.yml` + +Two edits, both for `BitcoinWalletExternalAccountInfo`. `common/BitcoinWalletInfo.yaml` gets +no entry — no `common/*WalletInfo` schema is registered. + +- **9a — model entry** under `resources.customers.subresources.external_accounts.models`, + alongside the other wallet members (`spark_wallet_info` … `base_wallet_info`, lines + 198–204): + ```yaml + bitcoin_wallet_info: '#/components/schemas/BitcoinWalletExternalAccountInfo' + ``` + Not the `$shared.models` block: `ethereum_wallet_external_account_info` (line 482) sits + there as a one-off, while every other wallet uses the `external_accounts` block. Follow the + majority. +- **9b — `allOf[0]` strip target** in the existing *"Remove $ref to BaseExternalAccountInfo + from external account variants"* transform (the list ending at line 937): + ```yaml + - "$.components.schemas.BitcoinWalletExternalAccountInfo.allOf[0]" + ``` +- **Why**: an unregistered component schema fails the `preview` SDK-build check, and a member + left `allOf`-inheriting `BaseExternalAccountInfo` breaks the TS build once the transform + strips `accountType` off that base, leaving it an empty object (TS2312). + +### 10. `openapi.yaml` + `mintlify/openapi.yaml` (generated) + +- **What**: regenerate via `make build`. Never hand-edited. + +### Not changing + +- `openapi/components/schemas/quotes/AccountDestination.yaml` — settled in #736. +- `info.version` — every change here is additive, so no version bump and no new + `servers.url` path. + +## Verification + +- [ ] `make build` — bundles regenerate; re-run and confirm byte-identical (no drift). +- [ ] `make lint-openapi` — redocly bundle + redocly lint + spectral, 0 errors. +- [ ] `git diff --stat` on the bundles shows only the intended additions. +- [ ] Grep both bundles for `refundAccountId`, `BITCOIN_L1`, and + `REFUND_DESTINATION_REQUIRED` — each present, and `refundAddress` absent everywhere. +- [ ] Confirm the `BITCOIN_L1` discriminator mapping resolves in the bundled + `ExternalAccountInfoOneOf` (both the oneOf list and the mapping). +- [ ] CI: `Detect breaking changes`, both `Lint Code & Documentation` jobs, both + `Build OpenAPI Documentation` jobs, `preview` (SDK builds), `Mintlify Deployment`. + Verify the runs are attached to the actual head SHA — bolt pushes do not reliably + trigger `pull_request` workflows in this repo. +- [ ] `bolt-codex-review` before opening the PR. + +No tests: documentation/spec-only repo with no application code. Lint + bundle-drift + +the SDK preview build are the executable checks. + +## Risks + +- **`breaking-change` label expected.** `request-property-added` on the quote source and + `response-property-one-of-added` for the new external-account member. Both additive, no + removals — the same shape #736 shipped with, gated on API-reviewer approval. +- **`ExternalAccountType` enum growth propagates** to `accountType` on every member + inheriting `BaseExternalAccountInfo`, so expect a wide-but-shallow oasdiff warning list — + the same shape #736 produced when `BITCOIN_L1` landed on `PaymentAccountType`. If the + breaking-changes comment overflows again, the fix is the comment-bounding already merged in + #736, not a schema retreat. +- **The `preview` SDK build fails if `.stainless/stainless.yml` and the schema set disagree.** + `BitcoinWalletExternalAccountInfo` needs both the model entry and the `allOf[0]` strip + target; missing the latter surfaces as a TS2312 build error rather than a config error. +- **Server side does not implement this yet.** The spec would describe `refundAccountId` and + `REFUND_DESTINATION_REQUIRED` ahead of sparkcore honoring them. Worth confirming with + @peterrojs whether the spec should lead (it did for `BITCOIN_L1`) or land alongside the + server change. +- **Open design questions from the #736 thread that this plan takes a position on**, and + which reviewers may want to redirect: + 1. Whether the refund target is caller-supplied at all, versus always derived from the + transaction. This plan makes it optional-with-fallback, which supports both. + 2. Whether an L1 address should be a first-class external account. This plan says yes, on + the grounds that it is the only way to get whitelisting and 1P verification onto it. + 3. Which regions require 1P verification — left to `beneficiaryVerificationStatus` and + server policy rather than encoded in the schema. From b81389fa8be78513442f697199c5f084c5e3d9f4 Mon Sep 17 00:00:00 2001 From: "Peter.Rojs" Date: Fri, 31 Jul 2026 20:45:47 +0000 Subject: [PATCH 2/2] feat: model the realtime-funding refund destination as an external account Adds an optional `refundAccountId` to `RealtimeFundingQuoteSource`, referencing an existing external account rather than a free-text address, so the refund target goes through whitelisting (`status`), the trust/untrust confirm flow, and beneficiary verification instead of bypassing them. Documents the fallback chain: omitted falls back to the customer's internal account for that asset, and a customer with no internal account to fall back on gets a new `REFUND_DESTINATION_REQUIRED` 400 rather than a deposit that cannot be returned. Bitcoin L1 was the only crypto corridor with no `ExternalAccountType` member, so a BTC L1 refund target was inexpressible. Adds `BITCOIN_L1` plus `BitcoinWalletExternalAccountInfo` and its shared `BitcoinWalletInfo` address shape, wired into both external-account oneOfs. Co-Authored-By: jklein24 Co-Authored-By: peter.rojs --- .stainless/stainless.yml | 2 + ...-31-refund-destination-external-account.md | 256 ------------------ mintlify/openapi.yaml | 51 +++- openapi.yaml | 51 +++- .../schemas/common/BitcoinWalletInfo.yaml | 13 + .../components/schemas/errors/Error400.yaml | 2 + .../BitcoinWalletExternalAccountInfo.yaml | 4 + .../ExternalAccountCreateInfoOneOf.yaml | 2 + .../ExternalAccountInfoOneOf.yaml | 2 + .../ExternalAccountType.yaml | 1 + .../quotes/RealtimeFundingQuoteSource.yaml | 15 + openapi/paths/quotes/quotes.yaml | 20 +- 12 files changed, 160 insertions(+), 259 deletions(-) delete mode 100644 docs/plans/2026-07-31-refund-destination-external-account.md create mode 100644 openapi/components/schemas/common/BitcoinWalletInfo.yaml create mode 100644 openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml diff --git a/.stainless/stainless.yml b/.stainless/stainless.yml index 38f41cd35..dd5b88afc 100644 --- a/.stainless/stainless.yml +++ b/.stainless/stainless.yml @@ -202,6 +202,7 @@ resources: polygon_wallet_info: '#/components/schemas/PolygonWalletExternalAccountInfo' lightning_wallet_info: '#/components/schemas/LightningExternalAccountInfo' base_wallet_info: '#/components/schemas/BaseWalletExternalAccountInfo' + bitcoin_wallet_info: '#/components/schemas/BitcoinWalletExternalAccountInfo' external_account: '#/components/schemas/ExternalAccount' external_account_create: '#/components/schemas/ExternalAccountCreateRequest' external_account_info_one_of: "#/components/schemas/ExternalAccountInfoOneOf" @@ -935,6 +936,7 @@ openapi: - "$.components.schemas.PlasmaWalletExternalAccountInfo.allOf[0]" - "$.components.schemas.PolygonWalletExternalAccountInfo.allOf[0]" - "$.components.schemas.BaseWalletExternalAccountInfo.allOf[0]" + - "$.components.schemas.BitcoinWalletExternalAccountInfo.allOf[0]" keys: [ "$ref" ] # ── Remove $ref to BaseQuoteSource from quote source variants ── diff --git a/docs/plans/2026-07-31-refund-destination-external-account.md b/docs/plans/2026-07-31-refund-destination-external-account.md deleted file mode 100644 index 4d783f23a..000000000 --- a/docs/plans/2026-07-31-refund-destination-external-account.md +++ /dev/null @@ -1,256 +0,0 @@ -# Refund destination as a source external account - -## Context - -PR #736 (Bitcoin L1 payment instruction, merged 2026-07-30) originally added a free-text -`refundAddress` to `RealtimeFundingQuoteSource` and `AccountDestination`. Both were pulled -before merge and deferred to a follow-up. This is that follow-up. - -@jklein24's review, [thread `r3686228352`](https://github.com/lightsparkdev/grid-api/pull/736#discussion_r3686228352): - -> I'm wondering if it might be good to use an external account here for whitelisting and -> compliance reasons. We have a status field on external accounts that lets us approve -> things ahead of time, etc. - -and later in that thread, per the linked Slack discussion: *"frame this as a source external -account… we'll need to validate that these are 1P wallets in some cases."* @peterrojs chose -option (b) — hold the raw field, model it properly in a follow-up. - -The destination-side field is *not* coming back. [Thread `r3670181155`](https://github.com/lightsparkdev/grid-api/pull/736#discussion_r3670181155) -settled that: a failed crypto payout refunds to a Grid-controlled address Grid already knows, -and `AccountDestination` also covers fiat accounts where a refund *address* is meaningless. -@jklein24: *"Why is this inside of the destination object?"* - -## Approach - -Add `refundAccountId` to `RealtimeFundingQuoteSource` — a reference to an existing -`ExternalAccount`, not an address string. The refund target then inherits everything an -external account carries: `status` (`PENDING`/`ACTIVE`/`UNDER_REVIEW`/`INACTIVE`) for -pre-approval, the trust/untrust confirm flow under -`/customers/external-accounts/{id}/trust|untrust`, and beneficiary verification -(`beneficiaryVerificationStatus`) for the 1P-wallet checks Jeremy flagged. A free-text -address bypasses all of it. - -Rationale for the specific shape: - -- **A plain `accountId` string, not a `$ref` to a reference object.** `AccountQuoteSource` - and `AccountDestination` both take a bare `accountId: string`. Matching that keeps the - quote schemas uniform. `transfers/ExternalAccountReference.yaml` exists but is a - request-body *root* schema for `/transfer-in`, not an embedded field idiom. -- **Named `refundAccountId`, not `refundAddress` or `refundExternalAccountId`.** The `-AccountId` - suffix matches `accountId`/`customerId` elsewhere, and the name no longer implies a raw - address. Alternative considered: keep `refundAddress` as a deprecated alias — rejected, - nothing shipped it, so there is nothing to alias. -- **Optional, with a documented fallback chain.** Grid falls back to the customer's internal - account for that asset. When there is neither a refund account nor an internal account to - fall back on — the no-custody passthrough case — quote creation is rejected rather than - accepting a deposit it cannot unwind. That rejection is currently undocumented behavior; - this plan surfaces it as a real `400` code. -- **`BITCOIN_L1` external-account type.** Without it a BTC L1 refund target cannot be - expressed at all. Every other crypto corridor (`BASE_WALLET`, `ETHEREUM_WALLET`, - `PLASMA_WALLET`, `POLYGON_WALLET`, `SOLANA_WALLET`, `SPARK_WALLET`, `TRON_WALLET`) already - has a member, so this is the only new type needed. - -Not doing: any `AccountDestination` field (settled), and no change to how refunds are -*derived* when no refund account is given — "in most cases we can get that from the -transaction" stays true and stays server-side. - -## Relevant Knowledge - -- Bundle conflicts resolve by `make build` — never hand-merge `openapi.yaml` / - `mintlify/openapi.yaml`. -- **Adding a component schema requires a matching `.stainless/stainless.yml` model entry**, - or the `preview` SDK-build check fails. -- `Detect breaking changes` (oasdiff) will flag `request-property-added` / - `response-property-one-of-added` here. Adding an enum value to a shared type propagates to - every member inheriting it: `ExternalAccountType` is referenced through - `BaseExternalAccountInfo`, so the blast radius is every external-account member's - `accountType`. #736 already took the equivalent hit on `PaymentAccountType` (`BITCOIN_L1` - landed there, `common/PaymentAccountType.yaml:30`), so this is a known-shape finding. -- `common/*WalletInfo.yaml` schemas are **not** registered in `.stainless/stainless.yml` - (neither `EthereumWalletInfo` nor `SparkWalletInfo` appears there) — they are hoisted into - the bundle by redocly and consumed through `allOf`, so only the - `*ExternalAccountInfo` wrapper needs a model entry. -- oasdiff against a stale base produces bogus breaking findings — rebase before trusting it. -- Bolt pushes do not always trigger this repo's `pull_request` OpenAPI workflows; verify the - runs are attached to the actual head commit rather than trusting a green rollup. -- Server side for context (no change here): `EntGridOrchestraSettlementInfo` already carries - `refund_address` + `RefundAddressOwner` on the source chain, so a caller-supplied refund - account maps onto an existing column rather than needing new provider state. - -## Changes - -### 1. `openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml` - -- **What**: add an optional `refundAccountId` property. -- **Why**: the one place the refund target genuinely cannot be inferred — an inbound on-chain - deposit whose sender Grid cannot derive. -- **Code sketch**: - ```yaml - refundAccountId: - type: string - description: >- - External account the funds are returned to if an on-chain deposit cannot be - routed to complete the payment. Must be an existing external account belonging - to `customerId` whose `currency` and network match the funding source, and whose - `status` is `ACTIVE`. Only meaningful when `currency` is a crypto asset. - - If omitted, funds are returned to the customer's internal account for that - asset. When the customer has no internal account to fall back on, quote - creation fails with `REFUND_DESTINATION_REQUIRED` rather than accepting a - deposit that cannot be returned. - example: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 - ``` - -### 2. `openapi/components/schemas/common/BitcoinWalletInfo.yaml` (new) - -- **What**: the shared BTC L1 address shape, mirroring `EthereumWalletInfo.yaml`. -- **Why**: `common/` holds the address shape so both the external-account member and any - future payment-instruction member reuse it — the established pattern for every other chain. -- **Code sketch**: - ```yaml - type: object - required: - - address - - accountType - properties: - accountType: - type: string - enum: - - BITCOIN_L1 - address: - type: string - description: Bitcoin (L1) on-chain address - example: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq - ``` - -### 3. `openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml` (new) - -- **What**: the external-account oneOf member. -- **Why**: makes a BTC L1 address a first-class, whitelistable external account. -- **Code sketch**: - ```yaml - title: Bitcoin L1 Wallet - allOf: - - $ref: ./BaseExternalAccountInfo.yaml - - $ref: ../common/BitcoinWalletInfo.yaml - ``` - -### 4. `openapi/components/schemas/external_accounts/ExternalAccountType.yaml` - -- **What**: add `BITCOIN_L1` to the enum. -- **Why**: the discriminator value for the new member. - -### 5. `openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml` - -- **What**: add the `$ref` and the `BITCOIN_L1` discriminator mapping entry. -- **Code sketch**: - ```yaml - - $ref: ./BitcoinWalletExternalAccountInfo.yaml - # … - BITCOIN_L1: ./BitcoinWalletExternalAccountInfo.yaml - ``` - -### 6. `openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml` - -- **What**: same two additions on the create side. -- **Why**: the other wallet types reuse their `*ExternalAccountInfo.yaml` for create (no - separate `*CreateInfo` file), so this member follows suit — otherwise the type exists but - cannot be created. - -### 7. `openapi/components/schemas/errors/Error400.yaml` - -- **What**: add `REFUND_DESTINATION_REQUIRED` to the code table and the `enum`. -- **Why**: makes the rejection path documented API surface instead of a surprise. Both the - markdown table and the enum list need the entry — they are maintained in parallel in this - file. -- **Code sketch**: - ```yaml - | REFUND_DESTINATION_REQUIRED | The crypto funding source has no refund destination and the customer has no internal account for that asset; supply `source.refundAccountId` | - # …and in the enum list: - - REFUND_DESTINATION_REQUIRED - ``` - -### 8. `openapi/paths/quotes/quotes.yaml` - -- **What**: extend the existing `realTimeFundingToSparkWallet` request example with - `refundAccountId`, and note the new `400` code in the `400` response description. -- **Why**: the request example is the only place a caller sees a realtime-funding source - populated end-to-end. - -### 9. `.stainless/stainless.yml` - -Two edits, both for `BitcoinWalletExternalAccountInfo`. `common/BitcoinWalletInfo.yaml` gets -no entry — no `common/*WalletInfo` schema is registered. - -- **9a — model entry** under `resources.customers.subresources.external_accounts.models`, - alongside the other wallet members (`spark_wallet_info` … `base_wallet_info`, lines - 198–204): - ```yaml - bitcoin_wallet_info: '#/components/schemas/BitcoinWalletExternalAccountInfo' - ``` - Not the `$shared.models` block: `ethereum_wallet_external_account_info` (line 482) sits - there as a one-off, while every other wallet uses the `external_accounts` block. Follow the - majority. -- **9b — `allOf[0]` strip target** in the existing *"Remove $ref to BaseExternalAccountInfo - from external account variants"* transform (the list ending at line 937): - ```yaml - - "$.components.schemas.BitcoinWalletExternalAccountInfo.allOf[0]" - ``` -- **Why**: an unregistered component schema fails the `preview` SDK-build check, and a member - left `allOf`-inheriting `BaseExternalAccountInfo` breaks the TS build once the transform - strips `accountType` off that base, leaving it an empty object (TS2312). - -### 10. `openapi.yaml` + `mintlify/openapi.yaml` (generated) - -- **What**: regenerate via `make build`. Never hand-edited. - -### Not changing - -- `openapi/components/schemas/quotes/AccountDestination.yaml` — settled in #736. -- `info.version` — every change here is additive, so no version bump and no new - `servers.url` path. - -## Verification - -- [ ] `make build` — bundles regenerate; re-run and confirm byte-identical (no drift). -- [ ] `make lint-openapi` — redocly bundle + redocly lint + spectral, 0 errors. -- [ ] `git diff --stat` on the bundles shows only the intended additions. -- [ ] Grep both bundles for `refundAccountId`, `BITCOIN_L1`, and - `REFUND_DESTINATION_REQUIRED` — each present, and `refundAddress` absent everywhere. -- [ ] Confirm the `BITCOIN_L1` discriminator mapping resolves in the bundled - `ExternalAccountInfoOneOf` (both the oneOf list and the mapping). -- [ ] CI: `Detect breaking changes`, both `Lint Code & Documentation` jobs, both - `Build OpenAPI Documentation` jobs, `preview` (SDK builds), `Mintlify Deployment`. - Verify the runs are attached to the actual head SHA — bolt pushes do not reliably - trigger `pull_request` workflows in this repo. -- [ ] `bolt-codex-review` before opening the PR. - -No tests: documentation/spec-only repo with no application code. Lint + bundle-drift + -the SDK preview build are the executable checks. - -## Risks - -- **`breaking-change` label expected.** `request-property-added` on the quote source and - `response-property-one-of-added` for the new external-account member. Both additive, no - removals — the same shape #736 shipped with, gated on API-reviewer approval. -- **`ExternalAccountType` enum growth propagates** to `accountType` on every member - inheriting `BaseExternalAccountInfo`, so expect a wide-but-shallow oasdiff warning list — - the same shape #736 produced when `BITCOIN_L1` landed on `PaymentAccountType`. If the - breaking-changes comment overflows again, the fix is the comment-bounding already merged in - #736, not a schema retreat. -- **The `preview` SDK build fails if `.stainless/stainless.yml` and the schema set disagree.** - `BitcoinWalletExternalAccountInfo` needs both the model entry and the `allOf[0]` strip - target; missing the latter surfaces as a TS2312 build error rather than a config error. -- **Server side does not implement this yet.** The spec would describe `refundAccountId` and - `REFUND_DESTINATION_REQUIRED` ahead of sparkcore honoring them. Worth confirming with - @peterrojs whether the spec should lead (it did for `BITCOIN_L1`) or land alongside the - server change. -- **Open design questions from the #736 thread that this plan takes a position on**, and - which reviewers may want to redirect: - 1. Whether the refund target is caller-supplied at all, versus always derived from the - transaction. This plan makes it optional-with-fallback, which supports both. - 2. Whether an L1 address should be a first-class external account. This plan says yes, on - the grounds that it is the only way to get whitelisting and 1P verification onto it. - 3. Which regions require 1P verification — left to `beneficiaryVerificationStatus` and - server policy rather than encoded in the schema. diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index a6069a95d..c40d5b6a8 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -3660,6 +3660,21 @@ paths: lockedCurrencyAmount: 10000 immediatelyExecute: true description: Bitcoin reward payout! + cryptoFundingWithRefundAccount: + summary: On-chain crypto funding with an explicit refund destination. + value: + source: + sourceType: REALTIME_FUNDING + customerId: Customer:019542f5-b3e7-1d02-0000-000000000009 + currency: BTC + cryptoNetwork: BITCOIN + refundAccountId: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + destination: + destinationType: ACCOUNT + accountId: ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123 + lockedCurrencySide: SENDING + lockedCurrencyAmount: 100000 + description: On-chain deposit with a whitelisted refund destination. responses: '201': description: | @@ -3705,7 +3720,8 @@ paths: schema: $ref: '#/components/schemas/Quote' '400': - description: Bad request - Missing or invalid parameters + description: | + Bad request - Missing or invalid parameters. An on-chain crypto funding source with no `source.refundAccountId` and no internal account to fall back on is rejected with `REFUND_DESTINATION_REQUIRED`. content: application/json: schema: @@ -11807,6 +11823,7 @@ components: | STABLECOIN_PROVIDER_ACCOUNT_INVALID | The stablecoin provider account link is not usable | | STABLECOIN_PROVIDER_ACCOUNT_REVOKED | The stablecoin provider account link has been revoked | | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | + | REFUND_DESTINATION_REQUIRED | The crypto funding source has no refund destination and the customer has no internal account for that asset; pass `source.refundAccountId` | enum: - INVALID_INPUT - MISSING_MANDATORY_USER_INFO @@ -11847,6 +11864,7 @@ components: - STABLECOIN_PROVIDER_ACCOUNT_INVALID - STABLECOIN_PROVIDER_ACCOUNT_REVOKED - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED + - REFUND_DESTINATION_REQUIRED message: type: string description: Error message @@ -16758,6 +16776,7 @@ components: - ZMW_ACCOUNT - SWIFT_ACCOUNT - BASE_WALLET + - BITCOIN_L1 - ETHEREUM_WALLET - LIGHTNING - PLASMA_WALLET @@ -18828,6 +18847,25 @@ components: allOf: - $ref: '#/components/schemas/BaseExternalAccountInfo' - $ref: '#/components/schemas/BaseWalletInfo' + BitcoinWalletInfo: + type: object + required: + - address + - accountType + properties: + accountType: + type: string + enum: + - BITCOIN_L1 + address: + type: string + description: Bitcoin (L1) on-chain address + example: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq + BitcoinWalletExternalAccountInfo: + title: Bitcoin L1 Wallet + allOf: + - $ref: '#/components/schemas/BaseExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletInfo' EthereumWalletExternalAccountInfo: title: Ethereum L1 Wallet allOf: @@ -18977,6 +19015,7 @@ components: - $ref: '#/components/schemas/ZmwExternalAccountInfo' - $ref: '#/components/schemas/SwiftExternalAccountInfo' - $ref: '#/components/schemas/BaseWalletExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletExternalAccountInfo' - $ref: '#/components/schemas/EthereumWalletExternalAccountInfo' - $ref: '#/components/schemas/LightningExternalAccountInfo' - $ref: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -19026,6 +19065,7 @@ components: ZMW_ACCOUNT: '#/components/schemas/ZmwExternalAccountInfo' SWIFT_ACCOUNT: '#/components/schemas/SwiftExternalAccountInfo' BASE_WALLET: '#/components/schemas/BaseWalletExternalAccountInfo' + BITCOIN_L1: '#/components/schemas/BitcoinWalletExternalAccountInfo' ETHEREUM_WALLET: '#/components/schemas/EthereumWalletExternalAccountInfo' LIGHTNING: '#/components/schemas/LightningExternalAccountInfo' LIGHTNING_ACCOUNT: '#/components/schemas/LightningExternalAccountInfo' @@ -19897,6 +19937,7 @@ components: - $ref: '#/components/schemas/ZmwExternalAccountCreateInfo' - $ref: '#/components/schemas/SwiftExternalAccountCreateInfo' - $ref: '#/components/schemas/BaseWalletExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletExternalAccountInfo' - $ref: '#/components/schemas/EthereumWalletExternalAccountInfo' - $ref: '#/components/schemas/LightningExternalAccountInfo' - $ref: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -19946,6 +19987,7 @@ components: ZMW_ACCOUNT: '#/components/schemas/ZmwExternalAccountCreateInfo' SWIFT_ACCOUNT: '#/components/schemas/SwiftExternalAccountCreateInfo' BASE_WALLET: '#/components/schemas/BaseWalletExternalAccountInfo' + BITCOIN_L1: '#/components/schemas/BitcoinWalletExternalAccountInfo' ETHEREUM_WALLET: '#/components/schemas/EthereumWalletExternalAccountInfo' LIGHTNING: '#/components/schemas/LightningExternalAccountInfo' POLYGON_WALLET: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -21440,6 +21482,13 @@ components: type: string description: 'The crypto network to use for the funding source. Required when `currency` is a stablecoin (e.g. USDC, USDT). Specifies which network the customer will deposit on, so the correct deposit address can be generated. Example values: `SOLANA`, `ETHEREUM`, `BASE`, `POLYGON`, `SPARK`, `LIGHTNING`, `BITCOIN`.' example: SOLANA + refundAccountId: + type: string + description: |- + External account the funds are returned to if an on-chain deposit cannot be routed to complete the payment. Must be an existing external account belonging to `customerId`, with a `status` of `ACTIVE` and both a `currency` and a network matching the funding source — a USDC deposit cannot be refunded to a USDT account on the same network. Only meaningful when `currency` is a crypto asset. + + If omitted, funds are returned to the customer's internal account for that asset. When the customer has no internal account to fall back on, quote creation fails with `REFUND_DESTINATION_REQUIRED` rather than accepting a deposit that cannot be returned. + example: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 description: Fund the quote using a real-time funding source (RTP, SEPA Instant, Spark, Stables, etc.). This will require manual just-in-time funding using `paymentInstructions` in the response. Because quotes expire quickly, this option is only valid for instant payment methods. Do not try to fund a quote with a non-instant payment method (ACH, etc.). QuoteSourceOneOf: oneOf: diff --git a/openapi.yaml b/openapi.yaml index a6069a95d..c40d5b6a8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3660,6 +3660,21 @@ paths: lockedCurrencyAmount: 10000 immediatelyExecute: true description: Bitcoin reward payout! + cryptoFundingWithRefundAccount: + summary: On-chain crypto funding with an explicit refund destination. + value: + source: + sourceType: REALTIME_FUNDING + customerId: Customer:019542f5-b3e7-1d02-0000-000000000009 + currency: BTC + cryptoNetwork: BITCOIN + refundAccountId: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + destination: + destinationType: ACCOUNT + accountId: ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123 + lockedCurrencySide: SENDING + lockedCurrencyAmount: 100000 + description: On-chain deposit with a whitelisted refund destination. responses: '201': description: | @@ -3705,7 +3720,8 @@ paths: schema: $ref: '#/components/schemas/Quote' '400': - description: Bad request - Missing or invalid parameters + description: | + Bad request - Missing or invalid parameters. An on-chain crypto funding source with no `source.refundAccountId` and no internal account to fall back on is rejected with `REFUND_DESTINATION_REQUIRED`. content: application/json: schema: @@ -11807,6 +11823,7 @@ components: | STABLECOIN_PROVIDER_ACCOUNT_INVALID | The stablecoin provider account link is not usable | | STABLECOIN_PROVIDER_ACCOUNT_REVOKED | The stablecoin provider account link has been revoked | | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | + | REFUND_DESTINATION_REQUIRED | The crypto funding source has no refund destination and the customer has no internal account for that asset; pass `source.refundAccountId` | enum: - INVALID_INPUT - MISSING_MANDATORY_USER_INFO @@ -11847,6 +11864,7 @@ components: - STABLECOIN_PROVIDER_ACCOUNT_INVALID - STABLECOIN_PROVIDER_ACCOUNT_REVOKED - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED + - REFUND_DESTINATION_REQUIRED message: type: string description: Error message @@ -16758,6 +16776,7 @@ components: - ZMW_ACCOUNT - SWIFT_ACCOUNT - BASE_WALLET + - BITCOIN_L1 - ETHEREUM_WALLET - LIGHTNING - PLASMA_WALLET @@ -18828,6 +18847,25 @@ components: allOf: - $ref: '#/components/schemas/BaseExternalAccountInfo' - $ref: '#/components/schemas/BaseWalletInfo' + BitcoinWalletInfo: + type: object + required: + - address + - accountType + properties: + accountType: + type: string + enum: + - BITCOIN_L1 + address: + type: string + description: Bitcoin (L1) on-chain address + example: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq + BitcoinWalletExternalAccountInfo: + title: Bitcoin L1 Wallet + allOf: + - $ref: '#/components/schemas/BaseExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletInfo' EthereumWalletExternalAccountInfo: title: Ethereum L1 Wallet allOf: @@ -18977,6 +19015,7 @@ components: - $ref: '#/components/schemas/ZmwExternalAccountInfo' - $ref: '#/components/schemas/SwiftExternalAccountInfo' - $ref: '#/components/schemas/BaseWalletExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletExternalAccountInfo' - $ref: '#/components/schemas/EthereumWalletExternalAccountInfo' - $ref: '#/components/schemas/LightningExternalAccountInfo' - $ref: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -19026,6 +19065,7 @@ components: ZMW_ACCOUNT: '#/components/schemas/ZmwExternalAccountInfo' SWIFT_ACCOUNT: '#/components/schemas/SwiftExternalAccountInfo' BASE_WALLET: '#/components/schemas/BaseWalletExternalAccountInfo' + BITCOIN_L1: '#/components/schemas/BitcoinWalletExternalAccountInfo' ETHEREUM_WALLET: '#/components/schemas/EthereumWalletExternalAccountInfo' LIGHTNING: '#/components/schemas/LightningExternalAccountInfo' LIGHTNING_ACCOUNT: '#/components/schemas/LightningExternalAccountInfo' @@ -19897,6 +19937,7 @@ components: - $ref: '#/components/schemas/ZmwExternalAccountCreateInfo' - $ref: '#/components/schemas/SwiftExternalAccountCreateInfo' - $ref: '#/components/schemas/BaseWalletExternalAccountInfo' + - $ref: '#/components/schemas/BitcoinWalletExternalAccountInfo' - $ref: '#/components/schemas/EthereumWalletExternalAccountInfo' - $ref: '#/components/schemas/LightningExternalAccountInfo' - $ref: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -19946,6 +19987,7 @@ components: ZMW_ACCOUNT: '#/components/schemas/ZmwExternalAccountCreateInfo' SWIFT_ACCOUNT: '#/components/schemas/SwiftExternalAccountCreateInfo' BASE_WALLET: '#/components/schemas/BaseWalletExternalAccountInfo' + BITCOIN_L1: '#/components/schemas/BitcoinWalletExternalAccountInfo' ETHEREUM_WALLET: '#/components/schemas/EthereumWalletExternalAccountInfo' LIGHTNING: '#/components/schemas/LightningExternalAccountInfo' POLYGON_WALLET: '#/components/schemas/PolygonWalletExternalAccountInfo' @@ -21440,6 +21482,13 @@ components: type: string description: 'The crypto network to use for the funding source. Required when `currency` is a stablecoin (e.g. USDC, USDT). Specifies which network the customer will deposit on, so the correct deposit address can be generated. Example values: `SOLANA`, `ETHEREUM`, `BASE`, `POLYGON`, `SPARK`, `LIGHTNING`, `BITCOIN`.' example: SOLANA + refundAccountId: + type: string + description: |- + External account the funds are returned to if an on-chain deposit cannot be routed to complete the payment. Must be an existing external account belonging to `customerId`, with a `status` of `ACTIVE` and both a `currency` and a network matching the funding source — a USDC deposit cannot be refunded to a USDT account on the same network. Only meaningful when `currency` is a crypto asset. + + If omitted, funds are returned to the customer's internal account for that asset. When the customer has no internal account to fall back on, quote creation fails with `REFUND_DESTINATION_REQUIRED` rather than accepting a deposit that cannot be returned. + example: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 description: Fund the quote using a real-time funding source (RTP, SEPA Instant, Spark, Stables, etc.). This will require manual just-in-time funding using `paymentInstructions` in the response. Because quotes expire quickly, this option is only valid for instant payment methods. Do not try to fund a quote with a non-instant payment method (ACH, etc.). QuoteSourceOneOf: oneOf: diff --git a/openapi/components/schemas/common/BitcoinWalletInfo.yaml b/openapi/components/schemas/common/BitcoinWalletInfo.yaml new file mode 100644 index 000000000..5aa343cb0 --- /dev/null +++ b/openapi/components/schemas/common/BitcoinWalletInfo.yaml @@ -0,0 +1,13 @@ +type: object +required: + - address + - accountType +properties: + accountType: + type: string + enum: + - BITCOIN_L1 + address: + type: string + description: Bitcoin (L1) on-chain address + example: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq diff --git a/openapi/components/schemas/errors/Error400.yaml b/openapi/components/schemas/errors/Error400.yaml index 97bcaad55..c7753069f 100644 --- a/openapi/components/schemas/errors/Error400.yaml +++ b/openapi/components/schemas/errors/Error400.yaml @@ -53,6 +53,7 @@ properties: | STABLECOIN_PROVIDER_ACCOUNT_INVALID | The stablecoin provider account link is not usable | | STABLECOIN_PROVIDER_ACCOUNT_REVOKED | The stablecoin provider account link has been revoked | | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | + | REFUND_DESTINATION_REQUIRED | The crypto funding source has no refund destination and the customer has no internal account for that asset; pass `source.refundAccountId` | enum: - INVALID_INPUT - MISSING_MANDATORY_USER_INFO @@ -93,6 +94,7 @@ properties: - STABLECOIN_PROVIDER_ACCOUNT_INVALID - STABLECOIN_PROVIDER_ACCOUNT_REVOKED - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED + - REFUND_DESTINATION_REQUIRED message: type: string description: Error message diff --git a/openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml b/openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml new file mode 100644 index 000000000..d0cc3d07c --- /dev/null +++ b/openapi/components/schemas/external_accounts/BitcoinWalletExternalAccountInfo.yaml @@ -0,0 +1,4 @@ +title: Bitcoin L1 Wallet +allOf: + - $ref: ./BaseExternalAccountInfo.yaml + - $ref: ../common/BitcoinWalletInfo.yaml diff --git a/openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml b/openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml index 33bbac36e..082d6c8a0 100644 --- a/openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml +++ b/openapi/components/schemas/external_accounts/ExternalAccountCreateInfoOneOf.yaml @@ -38,6 +38,7 @@ oneOf: - $ref: ./ZmwExternalAccountCreateInfo.yaml - $ref: ./SwiftExternalAccountCreateInfo.yaml - $ref: ./BaseWalletExternalAccountInfo.yaml +- $ref: ./BitcoinWalletExternalAccountInfo.yaml - $ref: ./EthereumWalletExternalAccountInfo.yaml - $ref: ./LightningExternalAccountInfo.yaml - $ref: ./PolygonWalletExternalAccountInfo.yaml @@ -87,6 +88,7 @@ discriminator: ZMW_ACCOUNT: ./ZmwExternalAccountCreateInfo.yaml SWIFT_ACCOUNT: ./SwiftExternalAccountCreateInfo.yaml BASE_WALLET: ./BaseWalletExternalAccountInfo.yaml + BITCOIN_L1: ./BitcoinWalletExternalAccountInfo.yaml ETHEREUM_WALLET: ./EthereumWalletExternalAccountInfo.yaml LIGHTNING: ./LightningExternalAccountInfo.yaml POLYGON_WALLET: ./PolygonWalletExternalAccountInfo.yaml diff --git a/openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml b/openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml index 36440465f..74646f813 100644 --- a/openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml +++ b/openapi/components/schemas/external_accounts/ExternalAccountInfoOneOf.yaml @@ -37,6 +37,7 @@ oneOf: - $ref: ./ZmwExternalAccountInfo.yaml - $ref: ./SwiftExternalAccountInfo.yaml - $ref: ./BaseWalletExternalAccountInfo.yaml +- $ref: ./BitcoinWalletExternalAccountInfo.yaml - $ref: ./EthereumWalletExternalAccountInfo.yaml - $ref: ./LightningExternalAccountInfo.yaml - $ref: ./PolygonWalletExternalAccountInfo.yaml @@ -86,6 +87,7 @@ discriminator: ZMW_ACCOUNT: ./ZmwExternalAccountInfo.yaml SWIFT_ACCOUNT: ./SwiftExternalAccountInfo.yaml BASE_WALLET: ./BaseWalletExternalAccountInfo.yaml + BITCOIN_L1: ./BitcoinWalletExternalAccountInfo.yaml ETHEREUM_WALLET: ./EthereumWalletExternalAccountInfo.yaml LIGHTNING: ./LightningExternalAccountInfo.yaml LIGHTNING_ACCOUNT: ./LightningExternalAccountInfo.yaml diff --git a/openapi/components/schemas/external_accounts/ExternalAccountType.yaml b/openapi/components/schemas/external_accounts/ExternalAccountType.yaml index 177800ad5..bdc8d44e7 100644 --- a/openapi/components/schemas/external_accounts/ExternalAccountType.yaml +++ b/openapi/components/schemas/external_accounts/ExternalAccountType.yaml @@ -38,6 +38,7 @@ enum: - ZMW_ACCOUNT - SWIFT_ACCOUNT - BASE_WALLET +- BITCOIN_L1 - ETHEREUM_WALLET - LIGHTNING - PLASMA_WALLET diff --git a/openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml b/openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml index 6793b4ac7..69b46ca3b 100644 --- a/openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml +++ b/openapi/components/schemas/quotes/RealtimeFundingQuoteSource.yaml @@ -31,6 +31,21 @@ allOf: on, so the correct deposit address can be generated. Example values: `SOLANA`, `ETHEREUM`, `BASE`, `POLYGON`, `SPARK`, `LIGHTNING`, `BITCOIN`. example: SOLANA + refundAccountId: + type: string + description: >- + External account the funds are returned to if an on-chain deposit cannot be routed + to complete the payment. Must be an existing external account belonging to + `customerId`, with a `status` of `ACTIVE` and both a `currency` and a network + matching the funding source — a USDC deposit cannot be refunded to a USDT account + on the same network. Only meaningful when `currency` is a crypto asset. + + + If omitted, funds are returned to the customer's internal account for that asset. + When the customer has no internal account to fall back on, quote creation fails + with `REFUND_DESTINATION_REQUIRED` rather than accepting a deposit that cannot be + returned. + example: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 description: >- Fund the quote using a real-time funding source (RTP, SEPA Instant, Spark, Stables, etc.). This will require manual just-in-time funding using `paymentInstructions` in the response. diff --git a/openapi/paths/quotes/quotes.yaml b/openapi/paths/quotes/quotes.yaml index 9fbfa63f2..962591441 100644 --- a/openapi/paths/quotes/quotes.yaml +++ b/openapi/paths/quotes/quotes.yaml @@ -79,6 +79,21 @@ post: lockedCurrencyAmount: 10000 immediatelyExecute: true description: 'Bitcoin reward payout!' + cryptoFundingWithRefundAccount: + summary: On-chain crypto funding with an explicit refund destination. + value: + source: + sourceType: REALTIME_FUNDING + customerId: Customer:019542f5-b3e7-1d02-0000-000000000009 + currency: BTC + cryptoNetwork: BITCOIN + refundAccountId: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + destination: + destinationType: ACCOUNT + accountId: ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123 + lockedCurrencySide: SENDING + lockedCurrencyAmount: 100000 + description: 'On-chain deposit with a whitelisted refund destination.' responses: '201': description: | @@ -129,7 +144,10 @@ post: schema: $ref: ../../components/schemas/quotes/Quote.yaml '400': - description: Bad request - Missing or invalid parameters + description: > + Bad request - Missing or invalid parameters. An on-chain crypto funding source with + no `source.refundAccountId` and no internal account to fall back on is rejected with + `REFUND_DESTINATION_REQUIRED`. content: application/json: schema: