From bb86deaec6ed9430cf6cd0bfe68b5fc5b237a94a Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 15 Jun 2026 22:45:17 -0700 Subject: [PATCH 001/365] feat: add swapkit proof of concept as a DEX (#1489) * fix: remove midgard as a data source * fix: use the USD pools to determine the USD price of all other currencies * chore: remove commented code * fix: add strings-maya to .tx/config * fix: add new error text * fix: update documentation and agents * feat: start SwapKit proof of concept * fix: repair many things * fix: add provider information to preview screen, fix other issues * fix: update the supported currency list to include NEAR supported currencies * fix: update the supported currency list to include NEAR supported currencies * fix: udpate portal screen to show the right name and icon * fix: handle no routes error due to low swap value * fix: crash from click after search * fix: put API_KEY into the service.properties file * fix: remove dead code * fix: fee calculation * fix: add swapkit model classes to proguard-rules.pro * fix: handle errors better and improve the swap call * fix: add edge-to-edge MayaResultDialog * fix: exchange rates in other currencies * fix: SwapTradeResponse.kt compile issue --- .claude/agents/update-swapkit-currencies.md | 209 ++++ integrations/maya/SWAPKIT_PROTOCOL.md | 517 ++++++++ integrations/maya/build.gradle | 10 + integrations/maya/proguard-rules.pro | 3 +- .../maya/api/DispatchingSwapProvider.kt | 152 +++ .../wallet/integrations/maya/api/MayaApi.kt | 91 +- .../maya/api/MayaBlockchainApi.kt | 273 +++-- .../integrations/maya/api/SwapProvider.kt | 92 ++ .../wallet/integrations/maya/di/MayaModule.kt | 44 +- .../maya/model/MayaErrorResponse.kt | 4 + .../integrations/maya/model/SwapQuote.kt | 4 +- .../maya/model/SwapTradeResponse.kt | 4 +- .../maya/payments/MayaCryptoCurrency.kt | 1065 ++++++++++++++++- .../payments/parsers/CardanoAddressParser.kt | 28 + .../parsers/CardanoPaymentIntentParser.kt | 69 ++ .../payments/parsers/NearAddressParser.kt | 30 + .../parsers/NearPaymentIntentParser.kt | 69 ++ .../SimpleBase58PaymentIntentParser.kt | 73 ++ .../payments/parsers/SolanaAddressParser.kt | 25 + .../parsers/SolanaPaymentIntentParser.kt | 70 ++ .../payments/parsers/StarknetAddressParser.kt | 22 + .../parsers/StarknetPaymentIntentParser.kt | 66 + .../maya/payments/parsers/SuiAddressParser.kt | 22 + .../parsers/SuiPaymentIntentParser.kt | 66 + .../maya/payments/parsers/TonAddressParser.kt | 25 + .../parsers/TonPaymentIntentParser.kt | 69 ++ .../payments/parsers/TronAddressParser.kt | 22 + .../parsers/TronPaymentIntentParser.kt | 69 ++ .../maya/payments/parsers/XrpAddressParser.kt | 22 + .../parsers/XrpPaymentIntentParser.kt | 69 ++ .../maya/swapkit/SwapKitApiAggregator.kt | 516 ++++++++ .../maya/swapkit/SwapKitAuthInterceptor.kt | 40 + .../maya/swapkit/SwapKitConstants.kt | 37 + .../maya/swapkit/SwapKitEndpoint.kt | 57 + .../maya/swapkit/SwapKitWebApi.kt | 141 +++ .../maya/swapkit/model/SwapKitModels.kt | 155 +++ .../maya/ui/MayaAddressInputViewModel.kt | 8 +- .../maya/ui/MayaConversionPreviewFragment.kt | 10 + .../maya/ui/MayaConversionPreviewViewModel.kt | 10 +- .../maya/ui/MayaConvertCryptoFragment.kt | 24 +- .../maya/ui/MayaConvertCryptoViewModel.kt | 10 +- .../maya/ui/MayaConvertResultViewModel.kt | 2 - .../maya/ui/MayaPortalFragment.kt | 3 + .../integrations/maya/ui/MayaPortalScreen.kt | 35 +- .../integrations/maya/ui/MayaViewModel.kt | 87 +- .../convert_currency/ConvertViewViewModel.kt | 6 +- .../maya/ui/dialogs/MayaResultDialog.kt | 14 + .../integrations/maya/utils/MayaConfig.kt | 6 + .../integrations/maya/utils/SwapBackend.kt | 29 + .../src/main/res/drawable/ic_swapkit_logo.xml | 32 + .../content_conversion_preview_maya.xml | 11 + .../maya/src/main/res/values/strings-maya.xml | 196 +++ .../data/BuyAndSellDashServicesModel.kt | 6 +- .../BuyAndSellIntegrationsFragment.kt | 6 + .../wallet/ui/buy_sell/BuyAndSellScreen.kt | 29 +- .../wallet/ui/buy_sell/BuyAndSellViewModel.kt | 18 +- 56 files changed, 4594 insertions(+), 178 deletions(-) create mode 100644 .claude/agents/update-swapkit-currencies.md create mode 100644 integrations/maya/SWAPKIT_PROTOCOL.md create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SimpleBase58PaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpAddressParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpPaymentIntentParser.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt create mode 100644 integrations/maya/src/main/res/drawable/ic_swapkit_logo.xml diff --git a/.claude/agents/update-swapkit-currencies.md b/.claude/agents/update-swapkit-currencies.md new file mode 100644 index 0000000000..e4a12a0a6a --- /dev/null +++ b/.claude/agents/update-swapkit-currencies.md @@ -0,0 +1,209 @@ +--- +name: "update-swapkit-currencies" +description: "Fetches tokens from the SwapKit API and updates MayaCurrencyList with any new coins or tokens reachable from DASH via the providers the wallet uses. Use this agent whenever you need to sync the app's supported currency list with what SwapKit can route." +tools: ["*"] +--- + +# Update SwapKit Currency List + +## Purpose +Sync `MayaCurrencyList` in `MayaCryptoCurrency.kt` with the live tokens exposed by the SwapKit API. The SwapKit and Maya backends share `MayaCurrencyList` (same `CHAIN.ASSET[-CONTRACT]` notation), so a single curated list backs both `MayaApiAggregator` and `SwapKitApiAggregator`. This agent uses SwapKit as the source of truth for what assets the wallet should support when SwapKit is the active swap backend. + +## Key Files +- **Currency list**: `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt` +- **String resources**: `integrations/maya/src/main/res/values/strings-maya.xml` +- **Parsers directory**: `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/` +- **SwapKit constants** (provider list, API key): `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt` + +## Source of Truth + +**`GET /swapTo?sellAsset=DASH.DASH` is the source of truth.** This is the exact endpoint `SwapKitApiAggregator.refreshPools()` calls to populate the wallet's currency picker — every identifier it returns must have a matching entry in `MayaCurrencyList` so the picker can render it. Do NOT filter by provider during discovery: `DASH_SUPPORTED_PROVIDERS` applies at quote time only, and the picker shows everything `/swapTo` returns. + +`/tokens?provider=NAME` is supplementary — use it only to look up display metadata (`name`, `decimals`, `coingeckoId`) for an identifier already in the target set. Do not intersect. + +## Steps + +### 1. Fetch the target set from `/swapTo` + +The SwapKit API requires the `x-api-key` header. Read the key from `SwapKitConstants.API_KEY` (or override via the `SWAPKIT_API_KEY` env var if set): + +```bash +KEY="${SWAPKIT_API_KEY:-$(grep -E 'API_KEY' integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt | head -1 | sed -E 's/.*"([^"]+)".*/\1/')}" +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/swapTo?sellAsset=DASH.DASH" | jq -r '.[]' | sort -u +``` + +The full response is the **target set**. It includes everything reachable from DASH across every aggregated provider (MAYACHAIN, NEAR Intents, CHAINFLIP, GARDEN, FLASHNET, …). Do NOT filter by provider — `SwapKitApiAggregator` calls this endpoint without a provider filter and surfaces every result in the picker. + +Each entry is a `CHAIN.SYMBOL[-CONTRACT]` identifier. Uppercase the hex contract suffix when comparing against `MayaCurrencyList` (Maya stores `0X` uppercase). + +### 1b. Fetch token metadata for naming + +For each chain prefix that appears in the target set, fetch the token list of any provider that supports that chain — this is just to get human-readable `name`, `decimals`, and `coingeckoId` for the new identifiers. Reasonable starting points: + +```bash +# Provider lookup: which provider serves a given chain? +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/providers" \ + | jq -r '.[] | "\(.name)\t\(.supportedChainIds|join(","))"' + +# Token metadata for a given provider +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=MAYACHAIN_STREAMING" | jq '.tokens[]' +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=NEAR" | jq '.tokens[]' +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=CHAINFLIP_STREAMING" | jq '.tokens[]' +``` + +> Note: SwapKit currently returns `MAYACHAIN_STREAMING` (not bare `MAYACHAIN`) when you `/tokens?provider=MAYACHAIN_STREAMING`. The two share token lists per the protocol doc. + +The token list gives you the human display name (`name`) for the network string and confirms `decimals`. If multiple providers list the same identifier with different names, prefer the one from a chain-native provider (e.g. NEAR token from `NEAR`, not from a bridge). + +### 2. Extract existing assets from MayaCurrencyList + +Read `MayaCryptoCurrency.kt`. Find all `asset` string values inside `MayaCurrencyList.init {}`. These are lines like: + +```kotlin +"ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", +``` + +Build a set of existing asset strings (uppercase the contract suffix when comparing). + +### 3. Identify new assets + +Compare the SwapKit `/swapTo` target set against the existing set. Identifiers present in SwapKit but absent from the code are new — **all of them must be added**, regardless of which provider services them. The wallet's picker shows everything `/swapTo` returns, so missing entries become picker bugs. + +**Skip ONLY**: +- `DASH.DASH` — handled separately by the swap source side. +- `THOR.RUNE` — special-cased; already mapped via `MayaRuneCryptoCurrency`. + +Do NOT skip NEAR, SOL, BASE, OP, POL, ZEC, BCH, LTC, DOGE, AVAX, BSC, XRP, TRON, ATOM, etc. — if `/swapTo` returns it, it goes in. + +### 4. Categorize each new asset + +#### EVM Tokens (ETH.\* and ARB.\*) + +These share the Ethereum address format (`0x...`). Use `MayaEthereumTokenCryptoCurrency`. + +For an asset like `ETH.MOCA-0X53312F85BBA24C8CB99CFFC13BF82420157230D3`: +- `chain` = `ETH`, `symbol` = `MOCA`, `contractAddr` = `0X53312F85BBA24C8CB99CFFC13BF82420157230D3` +- `shortAlias` = last 5 hex chars of contractAddr = `230D3` +- `memoAsset` = `ETH.MOCA-230D3` +- `uriPrefix` = `symbol.lowercase()` = `"moca"` +- Display `name` = the SwapKit `name` field (e.g. `"Mocaverse"`); fall back to the symbol if SwapKit returned no name. + +Generate: +```kotlin +MayaEthereumTokenCryptoCurrency( + "MOCA", + "Mocaverse", // SwapKit token.name + "ETH.MOCA-0X53312F85BBA24C8CB99CFFC13BF82420157230D3", + EthereumPaymentIntentParser("moca", "ETH.MOCA-230D3"), + R.string.cryptocurrency_moca_code, + R.string.cryptocurrency_moca_ethereum_network +), +``` + +String resources to add: +```xml +MOCA +Mocaverse (Ethereum) +``` + +Network display name format: `"Name (Chain)"` where Chain is `Ethereum` for ETH and `Arbitrum` for ARB. + +**Naming convention for string resource IDs:** +- code: `cryptocurrency_{symbol.lowercase()}_code` +- network: `cryptocurrency_{symbol.lowercase()}_{chain.lowercase()}_network` + +If the same symbol already has a `_code` resource (e.g., `cryptocurrency_usdt_code`), reuse it but still add a new chain-specific network string. + +#### L1 Native Coins (new chains: ZEC, XRD, MAYA, KUJI, etc.) + +These need: +1. A new `Maya{Name}CryptoCurrency` class in `MayaCryptoCurrency.kt` +2. Possibly a new `{Chain}PaymentIntentParser` class in the parsers directory +3. String resources + +**Address format guide by chain:** + +| Chain | Address format | Parser class to use | +|-------|---------------|---------------------| +| ETH / ARB / BSC / AVAX / BASE / OP / POL | `0x[a-fA-F0-9]{40}` | `EthereumPaymentIntentParser` | +| BTC / DASH / LTC / BCH / DOGE | Base58Check / Bech32 | `BitcoinPaymentIntentParser` | +| ZEC | Base58Check (t-prefix) | `ZcashPaymentIntentParser` | +| THOR / MAYA chain | Bech32, prefix `thor` / `maya`, length 38 | `Bech32PaymentIntentParser` | +| KUJI | Bech32, prefix `kujira`, length 38 | `Bech32PaymentIntentParser` | +| ATOM (cosmoshub) | Bech32, HRP `cosmos`, length 39 | `Bech32PaymentIntentParser` | +| XRD (Radix) | Bech32, HRP `account_rdx`, length ~61 | `XrdPaymentIntentParser` | +| SOL (Solana) | Base58, 32–44 chars (no checksum) | new `SolanaPaymentIntentParser` | +| NEAR | implicit hex 64-char OR `*.near` | new `NearPaymentIntentParser` | +| TRON | Base58 starting with `T`, 34 chars | new `TronPaymentIntentParser` | +| XRP | Base58 starting with `r`, 25–35 chars | new `XrpPaymentIntentParser` | + +For a new L1, create a class extending `MayaBitcoinCryptoCurrency` (which uses 1e8 units — Maya's internal representation for all assets): + +```kotlin +open class Maya{Name}CryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "SYMBOL" + override val name: String = "Full Name" // from SwapKit token.name + override val asset: String = "CHAIN.SYMBOL" + override val exampleAddress: String = "example_address_here" + override val paymentIntentParser: PaymentIntentParser = ... + override val addressParser: AddressParser = ... + override val codeId: Int = R.string.cryptocurrency_{symbol_lower}_code + override val nameId: Int = R.string.cryptocurrency_{symbol_lower}_network +} +``` + +If the chain uses bech32 addresses, use: +- `Bech32AddressParser("prefix", length, null)` for the address parser +- `Bech32PaymentIntentParser("SYMBOL", "prefix", "prefix", length, "CHAIN.SYMBOL")` for the payment intent parser + +If a new `PaymentIntentParser` class file is needed (for non-Bech32 non-ETH chains), create it in the parsers directory following the `ZcashPaymentIntentParser.kt` pattern. + +> **Decimal note**: SwapKit reports per-chain `decimals` (18 for EVM, 8 for BTC/DASH, etc.). The wallet's internal representation always uses 1e8 (`MayaBitcoinCryptoCurrency`). Don't introduce per-asset decimal overrides — Maya's quote/swap pipeline already normalises everything to 1e8. + +### 5. Insert new entries into MayaCurrencyList + +- Add EVM token entries grouped by chain (ETH tokens first, then ARB tokens) before the KUJI block. +- Add new L1 coins at the end of the list, after `MayaRuneCryptoCurrency()`. + +Insertion point for EVM tokens — add after the last existing ARB entry: +```kotlin +// ... existing ARB.WSTETH entry ... +), +// NEW EVM TOKENS GO HERE + +MayaKujiraCryptoCurrency(), +``` + +Insertion point for new L1 coins — after `MayaRuneCryptoCurrency()`: +```kotlin +MayaRuneCryptoCurrency(), +// NEW L1 COINS GO HERE +``` + +### 6. Add string resources to strings-maya.xml + +Add new entries before ``: +```xml + +XXX +Full Name (Chain) +``` + +### 7. Verify + +After making changes: +- Check that all `R.string.*` references have corresponding entries in `strings-maya.xml`. +- Check that all new `PaymentIntentParser` classes are imported in `MayaCryptoCurrency.kt`. +- Check that the `currencyMap` key (asset string) matches exactly between the `MayaCryptoCurrency` subclass and the list entry. +- Run a price spot-check via SwapKit to confirm the new identifier resolves: `curl -s -H "x-api-key: $KEY" -X POST -H "Content-Type: application/json" -d '{"tokens":[{"identifier":""}]}' https://api.swapkit.dev/price` — `price_usd: 0` means SwapKit doesn't recognise the identifier (likely a transcription error in the contract address). +- Quick build check: `./gradlew :integrations:maya:compile_testNet3DebugKotlin`. + +## Important Conventions + +- **Memo alias (shortened asset)**: Use the last 5 hex characters of the contract address for EVM tokens. Example: contract `...3606EB48` → memo alias `ETH.USDC-6EB48`. Do NOT include the `0X` prefix in the memo alias. +- **Identifier casing**: SwapKit returns contract addresses in mixed case in `address`/`identifier`. Uppercase the hex suffix when storing in `MayaCurrencyList` so it matches the existing entries (Maya stores `0X` uppercase, e.g. `ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7`). +- **Unit scaling**: All Maya L1 classes extend `MayaBitcoinCryptoCurrency` (1e8 units per coin). ETH/ARB native ETH tokens use `MayaEthereumCryptoCurrency` (1e9 / GWEI). SwapKit's `decimals` field is informational only — do not propagate it into the class. +- **String IDs**: If a symbol already exists with a code resource (e.g., USDT already has `cryptocurrency_tether_code`), reuse the code string but add a new chain-specific network string. +- **`translatable="false"`** must be set on all coin code strings. +- **API key handling**: The key in `SwapKitConstants.API_KEY` is committed for development convenience. Do not echo it into commit messages or PR descriptions. Treat it as a secret in any external output. +- **Provider drift**: If `/swapTo?sellAsset=DASH.DASH` ever returns identifiers that aren't in `/tokens?provider=MAYACHAIN`, SwapKit has expanded DASH routing to a new provider — flag this rather than silently adding the asset, since the wallet's `DASH_SUPPORTED_PROVIDERS` whitelist would still exclude it at quote time. \ No newline at end of file diff --git a/integrations/maya/SWAPKIT_PROTOCOL.md b/integrations/maya/SWAPKIT_PROTOCOL.md new file mode 100644 index 0000000000..e15b5b2318 --- /dev/null +++ b/integrations/maya/SWAPKIT_PROTOCOL.md @@ -0,0 +1,517 @@ +# SwapKit Protocol Integration Documentation + +This document describes the SwapKit Protocol API — a candidate alternative/complement to the Maya integration for cross-chain swaps in the Dash Wallet. + +## Overview + +SwapKit is a cross-chain swap aggregator that routes swaps across multiple liquidity providers (THORChain, MAYAChain, Chainflip, 1inch, Uniswap, Jupiter, PancakeSwap, etc.) behind a single REST API. Where the existing Maya integration talks directly to a single protocol (`mayanode`), SwapKit performs price discovery across many providers and returns ranked routes (`RECOMMENDED`, `FASTEST`, `CHEAPEST`). + +Key differences vs. the Maya integration: + +| Aspect | Maya (current) | SwapKit | +|---|---|---| +| Routing | Single protocol (Mayanode) | Aggregated across 15+ providers | +| Quote endpoint | `GET /quote/swap` (idempotent) | `POST /v3/quote` (returns `routeId`) | +| Tx construction | Client builds DASH tx with OP_RETURN memo | Server returns ready-to-sign `tx` payload (PSBT / EVM / Cosmos / TRON) | +| API key | None | `x-api-key` header required | +| AML screening | None | Address screening on every `/v3/swap` | +| Asset notation | `CHAIN.ASSET[-CONTRACT]` | Same notation | + +## API Endpoints + +### Base URLs + +- **API root**: `https://api.swapkit.dev/` +- **v3 (price discovery + execution)**: `https://api.swapkit.dev/v3/` +- **Tracker UI**: `https://track.swapkit.dev/?hash={txHash}` +- **Dashboard (API key registration)**: `https://dashboard.swapkit.dev/` + +### Authentication + +All endpoints require the header: + +``` +x-api-key: +``` + +The key is also what binds requests to the partner's affiliate fee/address configuration in the dashboard. + +### Endpoint Summary + +| Method | Path | Purpose | +|---|---|---| +| GET | `/providers` | List all aggregated swap providers and their supported chains | +| GET | `/tokens?provider=NAME` | List supported tokens for a given provider | +| GET | `/swapTo?sellAsset=…` | Discover what assets a given asset can be swapped **to** | +| GET | `/swapFrom?buyAsset=…` | Discover what assets can be swapped **into** a given asset | +| POST | `/v3/quote` | Get ranked routes with `routeId` (no transaction data) | +| POST | `/v3/swap` | Build the signable transaction for a chosen `routeId` | +| POST | `/track` | Query swap status by tx hash + chain or by deposit address | +| POST | `/price` | Token price lookup (USD + CoinGecko metadata) | + +--- + +### 1. Providers + +**Endpoint**: `GET https://api.swapkit.dev/providers` + +Returns a list of every swap provider SwapKit aggregates. Use to discover which providers can handle DASH (currently MAYACHAIN / MAYACHAIN_STREAMING). + +**Response** (array of): + +- `name`: Provider identifier (e.g. `MAYACHAIN`, `THORCHAIN_STREAMING`, `CHAINFLIP`) +- `provider`: Provider reference name +- `keywords`: Array of keywords +- `count`: Number of supported tokens +- `logoURI`: Provider logo URL +- `url`: URL to the provider's full token list +- `supportedActions`: Array of actions (e.g. `["swap"]`) +- `supportedChainIds`: Array of chain IDs (numeric for EVM, e.g. `"1"`, `"42161"`; named for non-EVM, e.g. `"bitcoin"`, `"solana"`) + +**Known providers**: THORCHAIN, THORCHAIN_STREAMING, CHAINFLIP, CHAINFLIP_STREAMING, MAYACHAIN, MAYACHAIN_STREAMING, NEAR, ONEINCH, PANCAKESWAP, TRADERJOE_V2, UNISWAP_V2, UNISWAP_V3, CAVIAR_V1, JUPITER, CAMELOT_V3. + +> "Streaming" providers split a swap over multiple sub-swaps for better effective price on larger orders. + +--- + +### 2. Tokens + +**Endpoint**: `GET https://api.swapkit.dev/tokens?provider={NAME}` + +Returns the token list supported by a single provider. Necessary to know what `identifier` strings a given provider will accept in `/v3/quote`. + +**Response**: + +```json +{ + "provider": "MAYACHAIN", + "name": "MAYACHAIN", + "timestamp": "2025-01-11T16:31:04.355Z", + "version": { "major": 1, "minor": 0, "patch": 0 }, + "keywords": [], + "count": 10, + "tokens": [ { ...token... } ] +} +``` + +**Token object**: + +- `chain`: Blockchain identifier (e.g. `BTC`, `DASH`, `ETH`, `SOL`) +- `address`: Contract address (omitted for gas tokens) +- `chainId`: Chain ID (numeric for EVM, named otherwise) +- `ticker`: Symbol (e.g. `DASH`, `USDC`) +- `identifier`: **Primary key** for `/v3/quote` calls — e.g. `DASH.DASH`, `ETH.USDC-0xA0b86991…` +- `symbol`: Symbol with address info +- `name`: Display name +- `decimals`: Decimal precision +- `logoURI`: Token logo URL +- `coingeckoId`: CoinGecko identifier (when available) + +--- + +### 3. Swap Discovery — `/swapTo` and `/swapFrom` + +Lightweight endpoints for populating UI selectors. + +**`GET /swapTo?sellAsset={identifier}`** → array of identifiers buyable from the given asset. +**`GET /swapFrom?buyAsset={identifier}`** → array of identifiers that can be sold to receive the given asset. + +> Note the inversion: `/swapFrom` takes `buyAsset` (not `sellAsset`). + +Both return `string[]` — flat arrays of identifiers like `"BTC.BTC"`, `"ETH.USDC-0X…"`. Lists are long for ERC-20 tokens because they aggregate every provider. + +--- + +### 4. Quote — `/v3/quote` + +**Endpoint**: `POST https://api.swapkit.dev/v3/quote` + +Step 1 of the swap flow. Returns ranked routes; **no transaction data**. + +**Request body**: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `sellAsset` | string | yes | e.g. `"DASH.DASH"` | +| `buyAsset` | string | yes | e.g. `"BTC.BTC"` | +| `sellAmount` | string | yes | Decimal amount as string (e.g. `"0.1"`, not base units) | +| `slippage` | number | no | Max acceptable slippage % (e.g. `3`) | +| `sourceAddress` | string | no | Enables partial address screening at quote time | +| `destinationAddress` | string | no | Same | +| `providers` | string[] | no | Restrict to specific providers; omit for all | +| `affiliateFee` | number | no | Override in basis points, 0–1000 (max 10%) | +| `cfBoost` | boolean | no | Enable Chainflip boost | +| `maxExecutionTime` | number | no | Drop routes slower than this (seconds) | + +**Response**: + +```jsonc +{ + "quoteId": "uuid", + "routes": [ + { + "routeId": "uuid", // valid 60s, kept warm 5min + "providers": ["MAYACHAIN_STREAMING"], + "sellAsset": "DASH.DASH", + "buyAsset": "BTC.BTC", + "sellAmount": "0.1", + "expectedBuyAmount": "0.00057", + "expectedBuyAmountMaxSlippage": "0.00055", + "fees": [ /* inbound, network, affiliate, service, outbound, liquidity */ ], + "estimatedTime": { "inbound": 60, "swap": 10, "outbound": 600, "total": 670 }, + "totalSlippageBps": 35.0, + "legs": [ /* per-step detail */ ], + "warnings": [], + "meta": { + "assets": [ { "asset": "DASH.DASH", "price": 30.5, "image": "…" }, … ], + "tags": ["RECOMMENDED"] // or "FASTEST" / "CHEAPEST" + }, + "nextActions": { + "method": "POST", + "url": "/swap", + "payload": { "routeId": "…" } + } + } + ], + "providerErrors": [ { "provider": "...", "errorCode": "...", "message": "..." } ], + "error": null +} +``` + +**Tags** (`meta.tags`): + +- `RECOMMENDED` — best output/speed tradeoff (scoring formula: `outputScore × outputWeight + timeScore × timeWeight`) +- `CHEAPEST` — maximum output +- `FASTEST` — shortest `estimatedTime.total` + +**Errors** (top-level `error` field): + +| Code | HTTP | Meaning | +|---|---|---| +| `noRoutesFound` | 404 | No path between assets | +| `blackListAsset` | 400 | Asset blacklisted | +| `apiKeyInvalid` | 401 | Bad/missing key | +| `unauthorized` | 401 | Auth failure | +| `invalidRequest` | 400 | Body malformed | + +--- + +### 5. Swap — `/v3/swap` + +**Endpoint**: `POST https://api.swapkit.dev/v3/swap` + +Step 2. Validates balance + AML and returns a signable transaction. + +**Request body**: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `routeId` | string | yes | From a `/v3/quote` response. **Older than 60s → quote auto-refreshed; older than 5min → not cached, returns `swapRouteNotFound`** | +| `sourceAddress` | string | yes | Sending address (for sell asset's chain) | +| `destinationAddress` | string | yes | Receiving address (for buy asset's chain) | +| `disableBuildTx` | boolean | no | Skip building the signable tx | +| `disableBalanceCheck` | boolean | no | Skip on-chain balance check (default false) | +| `disableEstimate` | boolean | no | Skip on-chain gas estimation | +| `allowSmartContractSender` | boolean | no | Allow source as contract | +| `allowSmartContractReceiver` | boolean | no | Allow destination as contract | +| `disableSecurityChecks` | boolean | no | Bypass address format/security checks | +| `overrideSlippage` | boolean | no | Bypass the 5% deviation guard if quote refreshed | + +**Response** (extends quote route): + +| Field | Type | Notes | +|---|---|---| +| `swapId` | string | UUID of this swap response | +| `providers`, `sellAsset`, `buyAsset`, `sellAmount` | … | Echoed | +| `expectedBuyAmount`, `expectedBuyAmountMaxSlippage` | string | Refreshed pricing | +| `tx` | varies by chain | See below | +| `approvalTx` | object | EVM ERC-20 approval tx if needed (else absent) | +| `targetAddress` | string | Vault / contract / channel to deposit into | +| `inboundAddress` | string | Address being monitored for the deposit | +| `memo` | string | Routing instruction (e.g. THORChain `=:b:bc1q…` style) | +| `fees`, `estimatedTime`, `legs`, `warnings`, `meta`, `nextActions` | … | As in quote | +| `txType` | string | `"PSBT"`, `"EVM"`, etc. (also under `meta.txType`) | + +**`tx` payload by source chain**: + +| Chain family | Format | +|---|---| +| EVM (ETH, ARB, BSC, AVAX, BASE, …) | Ethers v6-style object: `{ to, from, gas, gasPrice, value, data }` | +| UTXO (BTC, BCH, LTC, DOGE) | Base64-encoded PSBT | +| ZCash | Base64 PSBT (BitGoJS UtxoPsbt) by default; unsigned PCZT on request | +| TRON | TronWeb `TransactionBuilder` object | +| Cosmos (THOR, MAYA) | Native Cosmos transaction object | +| **DASH** | **Not documented as a SwapKit-source chain — DASH appears as a destination via Maya, but check `/tokens?provider=MAYACHAIN` to confirm whether SwapKit accepts DASH as `sellAsset`. If so, the format is most likely PSBT (UTXO).** | + +> **Important for the Dash Wallet**: DASH-as-source through SwapKit needs verification. Maya treats DASH as a first-class chain; Chainflip and THORChain do not. If SwapKit only routes DASH via Maya, the `tx` payload may bottom out at Maya's familiar pattern (vault deposit + OP_RETURN memo) — but the PSBT/encoding question must be answered before any client work. + +**SLIP-0024 signing**: optionally available; contact SwapKit to enable signed payload verification. + +**Errors**: + +| Code | HTTP | Meaning | +|---|---|---| +| `swapRouteNotFound` | 404 | `routeId` expired (>5min) or invalid | +| `isSanctionedAddress` | 400 | Address flagged by Chainalysis/Elliptic | +| `apiKeyInvalid` / `unauthorized` | 401 | Bad/missing key | +| `insufficientBalance` | 400 | Source lacks the sell amount | +| `insufficientAllowance` | 400 | EVM token needs approval first (use `approvalTx`) | +| `unableToBuildTransaction` | — | Balance present but can't cover network fees | +| `invalidSourceAddress` / `invalidDestinationAddress` | 400 | Format / SC / security failure | +| `outputAmountDeviationTooHigh` | 400 | Refreshed quote diverged >5%; pass `overrideSlippage` to ignore | +| `noRoutesFound` | 404 | Liquidity dried up between quote and swap | + +**Latency note**: `/v3/swap` is materially slower than `/v3/quote` because it fetches UTXOs, builds the tx, runs balance check, and runs full address screening. NEAR/Chainflip deposit-channel opening can add ~2.5s. + +--- + +### 6. Track — `/track` + +**Endpoint**: `POST https://api.swapkit.dev/track` + +**Request body** — at least one identifier required: + +| Field | Type | Notes | +|---|---|---| +| `hash` | string | Tx hash (must be paired with `chainId`) | +| `chainId` | string | Chain ID matching the hash | +| `depositAddress` | string | NEAR Intents alternative to hash+chainId | + +**Response** (top level + a `legs[]` of the same shape for cross-chain stages): + +| Field | Type | Notes | +|---|---|---| +| `chainId`, `hash`, `block` | … | Tx coordinates | +| `type` | string | `swap`, `token_transfer`, … | +| `status` | enum | `not_started` / `pending` / `swapping` / `completed` / `refunded` / `unknown` / `failed` | +| `trackingStatus` | enum | **Deprecated** — use `status` | +| `fromAsset`, `fromAmount`, `fromAddress` | … | Source side | +| `toAsset`, `toAmount`, `toAddress` | … | Destination side | +| `finalisedAt` | number | UNIX seconds | +| `meta.provider`, `meta.providerAction`, `meta.images` | … | Branding for UI | +| `payload.memo` | string | Routing memo | +| `payload.evmCalldata` | string? | Present for EVM-driven swaps | +| `payload.thorname` | string? | Present for THORName usage | +| `legs[]` | array | Each leg has the same shape; represents inbound vs outbound chain | + +> Use the hosted UI as a fallback / deep link: `https://track.swapkit.dev/?hash={hash}`. + +--- + +### 7. Price — `/price` + +**Endpoint**: `POST https://api.swapkit.dev/price` + +Batch USD price + CoinGecko metadata lookup. Useful for the wallet's price display, fiat amount preview, and 24h change badges — and as an alternative to the current Maya client-side USD pool derivation. + +**Request body**: + +```json +{ + "tokens": [ + { "identifier": "DASH.DASH" }, + { "identifier": "BTC.BTC" }, + { "identifier": "ETH.USDC-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" } + ], + "metadata": true +} +``` + +| Field | Type | Notes | +|---|---|---| +| `tokens` | array | Required; each item is `{ "identifier": "..." }` | +| `metadata` | boolean | Documented but currently always-included regardless of value | + +**Response** (array, one per requested token): + +| Field | Type | Notes | +|---|---|---| +| `identifier` | string | Echoed identifier | +| `provider` | string | Empty in current responses | +| `price_usd` | number | **0 when token is unknown / misnamed** — treat 0 as "not found", not as "free" | +| `timestamp` | number | Milliseconds | +| `cg.id` | string | CoinGecko ID | +| `cg.name` | string | Display name | +| `cg.market_cap` | number | USD | +| `cg.total_volume` | number | 24h USD volume | +| `cg.price_change_24h_usd` | number | Absolute | +| `cg.price_change_percentage_24h_usd` | number | Percent | +| `cg.sparkline_in_7d` | number[] | For chart widgets | +| `cg.timestamp` | string | ISO 8601 | + +--- + +## Asset Notation + +Same convention as Maya: `CHAIN.ASSET[-CONTRACT]`. + +- `DASH.DASH` +- `BTC.BTC` +- `ETH.ETH` +- `ETH.USDC-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` +- `ARB.ARB-0x912ce59144191c1204e64559fe8253a0e49e6548` +- `SOL.SOL` + +The `identifier` returned by `/tokens` is always the canonical key — never construct identifiers by hand from a ticker. + +--- + +## Implementation Architecture (Proposed) + +If integrated into the wallet, a layered structure analogous to Maya would be: + +``` +SwapKitApi (Interface) + ↓ +SwapKitApiAggregator + ↓ +├── SwapKitWebApi (HTTP layer) +│ └── SwapKitEndpoint (Retrofit — api.swapkit.dev) +│ +├── (Per-chain transaction builders/signers) +│ ├── DASH: PSBT / native DASH tx (depends on what /v3/swap returns for DASH) +│ ├── EVM: ethers-style tx (likely out of scope for this wallet) +│ └── … +│ +└── FiatExchangeRateApi (or use /price directly) +``` + +**Suggested files** (when implementation begins): + +- `api/SwapKitApi.kt` — public interface +- `api/SwapKitWebApi.kt` — HTTP wiring +- `api/SwapKitEndpoint.kt` — Retrofit interface +- `api/RemoteDataSource.kt` — Retrofit factory with `x-api-key` interceptor +- `di/SwapKitModule.kt` — Hilt bindings +- `model/` — `Provider.kt`, `TokensResponse.kt`, `Token.kt`, `QuoteRequest.kt`, `QuoteResponse.kt`, `Route.kt`, `SwapRequest.kt`, `SwapResponse.kt`, `TrackRequest.kt`, `TrackResponse.kt`, `PriceRequest.kt`, `PriceResponse.kt` +- `utils/SwapKitConstants.kt` — base URL, default slippage, `routeId` TTLs + +### Suggested Swap Flow (DASH → X) + +1. **Discover**: `GET /providers` → confirm `MAYACHAIN(_STREAMING)` supports `dash`. `GET /tokens?provider=MAYACHAIN` → get `DASH.DASH` identifier. +2. **Quote**: `POST /v3/quote` with `sellAsset=DASH.DASH`, `buyAsset=…`, `sellAmount`, `slippage`. Pick a route (RECOMMENDED by default). +3. **Show user**: route summary, fees, estimated time, slippage warning. Refresh if `routeId` is older than ~60 s. +4. **Build**: `POST /v3/swap` with `routeId`, `sourceAddress`, `destinationAddress`. Handle errors: + - `swapRouteNotFound` → re-quote. + - `outputAmountDeviationTooHigh` → re-quote, optionally `overrideSlippage`. + - `insufficientBalance` / `unableToBuildTransaction` → ask user to lower amount. + - `isSanctionedAddress` → reject. +5. **Sign & broadcast**: decode/handle the `tx` payload according to `txType`. For DASH (UTXO via Maya) this is most likely a PSBT or a Maya-style vault-deposit + OP_RETURN-memo transaction. +6. **Track**: `POST /track` with the broadcast hash + DASH chain ID; poll until `status === "completed"` (or refunded/failed). +7. **Display price**: optionally use `POST /price` for live USD/fiat display. + +--- + +## Important Considerations + +### Authentication & Affiliate + +- The `x-api-key` header is mandatory. +- The same key drives the affiliate-fee/affiliate-address configuration in the partner dashboard. `affiliateFee` in `/v3/quote` overrides per-request (basis points, 0–1000). +- **Do not ship API keys in the client.** A proxy or remote-config secret is required, similar to how Uphold/Coinbase keys are handled today. + +### AML & Address Screening + +- Quote-time screening is partial; **full screening runs on every `/v3/swap` call**. +- A working quote does **not** guarantee a working swap — addresses can be refused at swap time (`isSanctionedAddress`). + +### Quote Lifecycle + +- Routes expire 60 s after issuance; the cache window is 5 min. +- After 60 s, `/v3/swap` will auto-refresh and may return `outputAmountDeviationTooHigh` if pricing drifted >5%. +- After 5 min, `/v3/swap` returns `swapRouteNotFound` and the client must call `/v3/quote` again. + +### Fees + +Up to six fee categories are returned per route: + +- **Inbound** — paid from the user's wallet (the only one that comes out of the source side). +- **Network** — chain transaction fee. +- **Affiliate** — per `affiliateFee` config. +- **Service** — SwapKit's operational fee. +- **Outbound** — destination-chain delivery fee. +- **Liquidity** — provider/liquidity-pool fee. + +Output amounts shown are already net of all fees except inbound. + +### Provider Errors vs Top-Level Errors + +`/v3/quote` returns: + +- A top-level `error` for request-level failures (auth, malformed body, no routes at all). +- `providerErrors[]` for per-provider failures while other providers still produced routes — **do not treat these as fatal**; they're informational. + +### Compatibility With Existing Maya Module + +- Asset notation and the general routing model overlap heavily, so `model/Amount.kt`, `model/SwapQuoteRequest.kt`, and the existing fiat-rate stack can mostly be reused. +- The biggest delta is that **SwapKit returns the transaction**, where the current Maya integration **builds the DASH transaction client-side** (vault deposit + OP_RETURN memo, no BIP69 sorting). For SwapKit-originated DASH swaps, the wallet must learn to parse and sign whatever payload SwapKit delivers (likely PSBT). + +--- + +## Testing Endpoints + +```bash +# Providers +curl -H "x-api-key: $KEY" "https://api.swapkit.dev/providers" + +# Tokens for MAYACHAIN +curl -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=MAYACHAIN" + +# What can DASH be swapped to? +curl -H "x-api-key: $KEY" "https://api.swapkit.dev/swapTo?sellAsset=DASH.DASH" + +# Quote DASH -> BTC +curl -X POST -H "x-api-key: $KEY" -H "Content-Type: application/json" \ + -d '{"sellAsset":"DASH.DASH","buyAsset":"BTC.BTC","sellAmount":"0.1","slippage":3}' \ + "https://api.swapkit.dev/v3/quote" + +# Build the swap +curl -X POST -H "x-api-key: $KEY" -H "Content-Type: application/json" \ + -d '{"routeId":"","sourceAddress":"","destinationAddress":""}' \ + "https://api.swapkit.dev/v3/swap" + +# Track +curl -X POST -H "x-api-key: $KEY" -H "Content-Type: application/json" \ + -d '{"hash":"","chainId":"dash"}' \ + "https://api.swapkit.dev/track" + +# Price +curl -X POST -H "x-api-key: $KEY" -H "Content-Type: application/json" \ + -d '{"tokens":[{"identifier":"DASH.DASH"},{"identifier":"BTC.BTC"}],"metadata":true}' \ + "https://api.swapkit.dev/price" +``` + +--- + +## Open Questions for the Dash Wallet + +These need to be answered before any client code is written: + +1. **Does SwapKit accept `DASH.DASH` as a `sellAsset`?** (Almost certainly yes via MAYACHAIN — verify against `/tokens?provider=MAYACHAIN` and a live `/v3/quote`.) +2. **What `txType` does `/v3/swap` return for DASH?** PSBT, or a JSON object describing a vault-deposit + OP_RETURN like the current Maya path? +3. **Where does the API key live?** In-app (insecure), proxied through a backend, or fetched from remote config like the Uphold/Coinbase keys? +4. **Affiliate fee policy.** What basis-point split, and configured at the dashboard or per request? +5. **Does SwapKit duplicate Maya's offering enough to *replace* the direct integration, or should it be an additional swap source presented alongside Maya?** + +--- + +## Official Documentation + +- **API Introduction**: https://docs.swapkit.dev/swapkit-api/introduction +- **Quote & Swap Flow**: https://docs.swapkit.dev/swapkit-api/quote-and-swap-implementation-flow +- **Providers**: https://docs.swapkit.dev/swapkit-api/providers-request-supported-chains-by-a-swap-provider +- **Tokens**: https://docs.swapkit.dev/swapkit-api/tokens-request-supported-tokens-by-a-swap-provider +- **swapFrom**: https://docs.swapkit.dev/swapkit-api/swapfrom-request-sell-swap-options +- **swapTo**: https://docs.swapkit.dev/swapkit-api/swapto-request-buy-swap-options +- **/v3/quote**: https://docs.swapkit.dev/swapkit-api/v3-quote-request-a-swap-quote +- **/v3/swap**: https://docs.swapkit.dev/swapkit-api/v3-swap-obtain-swap-transaction-details +- **/track**: https://docs.swapkit.dev/swapkit-api/track-request-the-status-of-a-swap +- **/price**: https://docs.swapkit.dev/swapkit-api/price-lookup-token-prices +- **Swagger UI**: https://api.swapkit.dev/docs +- **Tracker UI**: https://track.swapkit.dev/ +- **Dashboard**: https://dashboard.swapkit.dev/ + +## References + +- SwapKit: https://swapkit.dev/ +- Underlying providers leveraged for DASH swaps: Maya Protocol (https://www.mayaprotocol.com/) — see `MAYA_PROTOCOL.md` in this directory. \ No newline at end of file diff --git a/integrations/maya/build.gradle b/integrations/maya/build.gradle index 7bdbfddeaa..afd9789185 100644 --- a/integrations/maya/build.gradle +++ b/integrations/maya/build.gradle @@ -9,6 +9,12 @@ plugins { id 'org.jlleitschuh.gradle.ktlint' } +def serviceProps = new Properties() +def servicePropsFile = rootProject.file('service.properties') +if (servicePropsFile.exists()) { + servicePropsFile.withInputStream { serviceProps.load(it) } +} + android { namespace 'org.dash.wallet.integrations.maya' compileSdk 35 @@ -19,6 +25,9 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles 'proguard-rules.pro' + + def swapKitApiKey = serviceProps.getProperty("SWAPKIT_API_KEY", "\"\"") + buildConfigField("String", "SWAPKIT_API_KEY", swapKitApiKey) } buildTypes { @@ -37,6 +46,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } lint { disable "NullSafeMutableLiveData" diff --git a/integrations/maya/proguard-rules.pro b/integrations/maya/proguard-rules.pro index a4a1ea1814..a1e7c421eb 100644 --- a/integrations/maya/proguard-rules.pro +++ b/integrations/maya/proguard-rules.pro @@ -1,2 +1,3 @@ # Keep Maya model classes for Gson deserialization --keep class org.dash.wallet.integrations.maya.model.** { (...); *; } \ No newline at end of file +-keep class org.dash.wallet.integrations.maya.model.** { (...); *; } +-keep class org.dash.wallet.integrations.maya.swapkit.model.** { (...); *; } \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt new file mode 100644 index 0000000000..8db37cd46a --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.api + +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.integrations.maya.model.AccountDataUIModel +import org.dash.wallet.integrations.maya.model.InboundAddress +import org.dash.wallet.integrations.maya.model.PoolInfo +import org.dash.wallet.integrations.maya.model.SwapQuote +import org.dash.wallet.integrations.maya.model.SwapQuoteRequest +import org.dash.wallet.integrations.maya.model.SwapTradeUIModel +import org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator +import org.dash.wallet.integrations.maya.swapkit.SwapKitConstants +import org.dash.wallet.integrations.maya.utils.MayaConfig +import org.dash.wallet.integrations.maya.utils.SwapBackend +import org.slf4j.LoggerFactory +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Singleton [SwapProvider] that delegates to either [MayaApiAggregator] or + * [SwapKitApiAggregator] based on the persisted [MayaConfig.SWAP_BACKEND] preference. + * + * Switching the active backend at runtime is supported via [setBackend] — the next + * subscription on a property like [poolInfoList] (e.g. when a screen reopens) will + * resolve to the newly-selected backend's underlying flow. Existing subscriptions + * stay attached to their original flow until they're recollected, which matches + * the typical "back-out, tap the other entry, re-enter" UX pattern. + * + * If SwapKit is requested but no API key is configured, the dispatcher falls back + * to Maya so the wallet stays usable without credentials. + */ +@Singleton +class DispatchingSwapProvider @Inject constructor( + private val maya: MayaApiAggregator, + private val swapKit: SwapKitApiAggregator, + private val config: MayaConfig +) : SwapProvider { + companion object { + private val log = LoggerFactory.getLogger(DispatchingSwapProvider::class.java) + } + + @Volatile + private var activeBackend: SwapBackend = readPersistedBlocking() + + private val persistScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private fun readPersistedBlocking(): SwapBackend { + val configured = runBlocking { config.get(MayaConfig.SWAP_BACKEND) } + ?.let { runCatching { SwapBackend.valueOf(it) }.getOrNull() } + ?: SwapBackend.MAYA + return effective(configured) + } + + private fun effective(requested: SwapBackend): SwapBackend { + return if (requested == SwapBackend.SWAPKIT && SwapKitConstants.API_KEY.isBlank()) { + log.warn("SwapKit requested but no API key — falling back to Maya") + SwapBackend.MAYA + } else { + requested + } + } + + fun currentBackend(): SwapBackend = activeBackend + + /** + * Switches the active backend immediately (in-memory) and persists the choice + * asynchronously. Synchronous on purpose so callers can switch and navigate in + * the same thread tick without racing the new screen's first subscription. + */ + fun setBackend(requested: SwapBackend) { + val resolved = effective(requested) + activeBackend = resolved + persistScope.launch { config.set(MayaConfig.SWAP_BACKEND, resolved.name) } + } + + internal val active: SwapProvider + get() = when (activeBackend) { + SwapBackend.SWAPKIT -> swapKit + SwapBackend.MAYA -> maya + } + + override val poolInfoList: StateFlow> + get() = active.poolInfoList + + override val apiError: StateFlow + get() = active.apiError + + override var notificationIntent: Intent? + get() = active.notificationIntent + set(value) { active.notificationIntent = value } + + override var showNotificationOnResult: Boolean + get() = active.showNotificationOnResult + set(value) { active.showNotificationOnResult = value } + + override suspend fun reset() = active.reset() + + override fun observePoolList(fiatExchangeRate: Fiat): Flow> = + active.observePoolList(fiatExchangeRate) + + override suspend fun getInboundAddresses(): List = + active.getInboundAddresses() + + override suspend fun getDefaultSwapQuote(toAsset: String, value: Long): SwapQuote? = + active.getDefaultSwapQuote(toAsset, value) + + override suspend fun getDefaultSwapQuote( + toAsset: String, + destinationAddress: String, + value: Long + ): SwapQuote? = active.getDefaultSwapQuote(toAsset, destinationAddress, value) + + override suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource = + active.getSwapInfo(swapRequest) + + override suspend fun commitSwapTransaction( + tradeId: String, + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource = active.commitSwapTransaction(tradeId, swapTradeUIModel) + + override suspend fun getUserAccounts(currency: String): List = + active.getUserAccounts(currency) + + override fun applyPoolPrices(pools: List, usdToFiat: Fiat) { + active.applyPoolPrices(pools, usdToFiat) + } +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt index b381a3100c..5000306dbf 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt @@ -32,19 +32,33 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.bitcoinj.utils.Fiat import org.dash.wallet.common.WalletDataProvider +import org.dash.wallet.common.data.ResponseResource import org.dash.wallet.common.services.AuthenticationManager import org.dash.wallet.common.services.NotificationService import org.dash.wallet.common.services.TransactionMetadataProvider import org.dash.wallet.common.services.analytics.AnalyticsService +import org.dash.wallet.common.util.toBigDecimal +import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.InboundAddress import org.dash.wallet.integrations.maya.model.PoolInfo import org.dash.wallet.integrations.maya.model.SwapQuote +import org.dash.wallet.integrations.maya.model.SwapQuoteRequest +import org.dash.wallet.integrations.maya.model.SwapTradeUIModel import org.dash.wallet.integrations.maya.utils.MayaConfig import org.slf4j.LoggerFactory +import java.math.BigDecimal +import java.math.RoundingMode import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.inject.Inject +/** + * Legacy Maya-specific surface kept for backwards compatibility. + * + * New consumers should depend on [SwapProvider] instead — [MayaApiAggregator] implements + * both. Once every call site is migrated this interface can be removed. + */ interface MayaApi { val poolInfoList: StateFlow> val apiError: StateFlow @@ -56,6 +70,9 @@ interface MayaApi { fun observePoolList(fiatExchangeRate: Fiat): Flow> suspend fun getInboundAddresses(): List + // Default lives on [SwapProvider.getDefaultSwapQuote] — Kotlin refuses defaults + // declared on more than one super interface, so [MayaApiAggregator] gets the + // default solely from [SwapProvider]. suspend fun getDefaultSwapQuote(toAsset: String, value: Long = 1_0000_0000): SwapQuote? } @@ -68,7 +85,7 @@ class MayaApiAggregator @Inject constructor( private val config: MayaConfig, private val securityFunctions: AuthenticationManager, private val transactionMetadataProvider: TransactionMetadataProvider -) : MayaApi { +) : MayaApi, SwapProvider { companion object { private val log = LoggerFactory.getLogger(MayaApiAggregator::class.java) private val UPDATE_FREQ_MS = TimeUnit.SECONDS.toMillis(30) @@ -122,6 +139,29 @@ class MayaApiAggregator @Inject constructor( return webApi.getDefaultSwapQuote(toAsset, value) } + override suspend fun getDefaultSwapQuote( + toAsset: String, + destinationAddress: String, + value: Long + ): SwapQuote? { + return webApi.getDefaultSwapQuote(toAsset, destinationAddress, value) + } + + override suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource { + return webApi.getSwapInfo(swapRequest) + } + + override suspend fun commitSwapTransaction( + tradeId: String, + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource { + return blockchainApi.commitSwapTransaction(tradeId, swapTradeUIModel) + } + + override suspend fun getUserAccounts(currency: String): List { + return webApi.getUserAccounts(currency) + } + override suspend fun reset() { log.info("reset is triggered") poolInfoList.value = listOf() @@ -187,4 +227,53 @@ class MayaApiAggregator @Inject constructor( val now = System.currentTimeMillis() return poolListLastUpdated == 0L || now - poolListLastUpdated > UPDATE_FREQ_MS } + + override fun applyPoolPrices(pools: List, usdToFiat: Fiat) { + // Liquidity-weighted USD price of CACAO from all available USD-stable pools. + // Sum of asset balances / sum of cacao balances naturally weights by depth. + val stablePools = pools.filter { + (it.currencyCode == "USDT" || it.currencyCode == "USDC") && + it.status.equals("available", ignoreCase = true) + } + val sumStableCacao = stablePools.fold(BigDecimal.ZERO) { acc, p -> + acc + (p.balanceCacao.toBigDecimalOrNull() ?: BigDecimal.ZERO) + } + val sumStableAsset = stablePools.fold(BigDecimal.ZERO) { acc, p -> + acc + (p.balanceAsset.toBigDecimalOrNull() ?: BigDecimal.ZERO) + } + if (sumStableCacao.signum() <= 0 || sumStableAsset.signum() <= 0) { + log.warn("no stablecoin pool data; skipping price update") + return + } + log.info("stable pools: {} ({} pools)", stablePools.map { it.asset }, stablePools.size) + + // usdToFiat is the wallet's "1 USD in SELECTED_CURRENCY" rate. Each pool's + // USD price is computed via the (balance_cacao * Σstable_asset) / + // (balance_asset * Σstable_cacao) cross-product (CACAO's decimals cancel, + // and pool assets share 8 decimals so the result is USD per whole asset). + // Multiply by usdToFiat to land in the selected fiat. + val fiatPerUsd = usdToFiat.toBigDecimal() + + pools.forEach { pool -> + val priceUsd = priceInUsd(pool, sumStableCacao, sumStableAsset) + if (priceUsd == null || priceUsd.signum() <= 0) { + log.info("no USD price for {}", pool.asset) + return@forEach + } + pool.assetPriceFiat = priceUsd.multiply(fiatPerUsd).toFiat(usdToFiat.currencyCode) + log.info("$priceUsd, ${pool.assetPriceFiat} -> ${pool.asset}") + } + } + + private fun priceInUsd( + pool: PoolInfo, + sumStableCacao: BigDecimal, + sumStableAsset: BigDecimal + ): BigDecimal? { + val cacao = pool.balanceCacao.toBigDecimalOrNull() ?: return null + val asset = pool.balanceAsset.toBigDecimalOrNull() ?: return null + if (asset.signum() == 0 || sumStableCacao.signum() == 0) return null + return cacao.multiply(sumStableAsset) + .divide(asset.multiply(sumStableCacao), 10, RoundingMode.HALF_UP) + } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt index 1ce4e6393d..f1c175c908 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt @@ -38,10 +38,23 @@ import java.math.RoundingMode import javax.inject.Inject interface MayaBlockchainApi { + /** + * Re-fetches a fresh quote via Maya, then builds + signs + broadcasts the DASH transaction. + * Used by the direct Maya backend. + */ suspend fun commitSwapTransaction( tradeId: String, swapTradeUIModel: SwapTradeUIModel ): ResponseResource + + /** + * Builds + signs + broadcasts the DASH transaction for an already-resolved trade + * (vault address + memo + fee already populated). The SwapKit backend uses this + * directly after refreshing the route via SwapKit's own API. + */ + suspend fun buildAndSendSwapTx( + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource } class MayaBlockchainApiImpl @Inject constructor( private val sendPaymentService: SendPaymentService, @@ -57,7 +70,6 @@ class MayaBlockchainApiImpl @Inject constructor( swapTradeUIModel: SwapTradeUIModel ): ResponseResource { log.info("commitSwapTransaction($tradeId, $swapTradeUIModel") - val params = walletProviderData.networkParameters val resultSwapTrade = mayaWebApi.getSwapInfo( SwapQuoteRequest( amount = swapTradeUIModel.amount, @@ -68,147 +80,154 @@ class MayaBlockchainApiImpl @Inject constructor( maximum = swapTradeUIModel.maximum ) ) - if (resultSwapTrade is ResponseResource.Success) { - try { - val sendRequest: SendRequest - val memo = swapTradeUIModel.memo - ?: "=:${resultSwapTrade.value.outputAsset}:${resultSwapTrade.value.destinationAddress}" - val tx = Transaction(params) - - // set outputs according to: - // https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions#utxo-chains - // Send the transaction with Asgard vault as VOUT0 - if (!swapTradeUIModel.maximum) { - val dashAmountWithFees = if (!swapTradeUIModel.maximum) { - (resultSwapTrade.value.amount.dash + resultSwapTrade.value.feeAmount.dash) - } else { - resultSwapTrade.value.amount.dash - }.setScale(8, RoundingMode.HALF_UP).toCoin() - tx.addOutput( - dashAmountWithFees, - Address.fromBase58(params, resultSwapTrade.value.vaultAddress) - ) - // Include the memo as an OP_RETURN in VOUT1 - // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap - // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] - log.info("memo: {}", memo) - tx.addOutput( - TransactionOutput( - params, - tx, - Coin.ZERO, - ScriptBuilder.createOpReturnScript(memo.toByteArray()).program - ) - ) - sendRequest = SendRequest.forTx(tx) - } else { - sendRequest = SendRequest.emptyWallet(Address.fromBase58(params, swapTradeUIModel.vaultAddress)) - } + return if (resultSwapTrade is ResponseResource.Success) { + buildAndSendSwapTx(resultSwapTrade.value) + } else { + resultSwapTrade + } + } - // Override randomised VOUT ordering; MAYAChain requires specific output ordering. - sendRequest.sortByBIP69 = false // we don't want the output order changed - sendRequest.shuffleOutputs = false // we don't want the output order changed + override suspend fun buildAndSendSwapTx( + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource { + val params = walletProviderData.networkParameters + try { + val sendRequest: SendRequest + val memo = swapTradeUIModel.memo + ?: "=:${swapTradeUIModel.outputAsset}:${swapTradeUIModel.destinationAddress}" + val tx = Transaction(params) + + // set outputs according to: + // https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions#utxo-chains + // Send the transaction with Asgard vault as VOUT0 + if (!swapTradeUIModel.maximum) { + val dashAmountWithFees = if (!swapTradeUIModel.maximum) { + (swapTradeUIModel.amount.dash + swapTradeUIModel.feeAmount.dash) + } else { + swapTradeUIModel.amount.dash + }.setScale(8, RoundingMode.HALF_UP).toCoin() + tx.addOutput( + dashAmountWithFees, + Address.fromBase58(params, swapTradeUIModel.vaultAddress) + ) + // Include the memo as an OP_RETURN in VOUT1 + // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap + // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] + log.info("memo: {}", memo) + tx.addOutput( + TransactionOutput( + params, + tx, + Coin.ZERO, + ScriptBuilder.createOpReturnScript(memo.toByteArray()).program + ) + ) + sendRequest = SendRequest.forTx(tx) + } else { + sendRequest = SendRequest.emptyWallet(Address.fromBase58(params, swapTradeUIModel.vaultAddress)) + } - // this will complete the transaction by adding inputs and an output for change - sendPaymentService.completeTransaction(sendRequest) + // Override randomised VOUT ordering; MAYAChain requires specific output ordering. + sendRequest.sortByBIP69 = false // we don't want the output order changed + sendRequest.shuffleOutputs = false // we don't want the output order changed + + // this will complete the transaction by adding inputs and an output for change + sendPaymentService.completeTransaction(sendRequest) + + // verify that there are only 3 outputs in the transaction + if (!swapTradeUIModel.maximum && sendRequest.tx.outputs.size != 3) { + return ResponseResource.Failure( + IncorrectSwapOutputCount(sendRequest.tx), + false, + 0, + null + ) + } - // verify that there are only 3 outputs in the transaction - if (!swapTradeUIModel.maximum && sendRequest.tx.outputs.size != 3) { - return ResponseResource.Failure( - IncorrectSwapOutputCount(sendRequest.tx), + if (swapTradeUIModel.maximum) { + // Include the memo as an OP_RETURN in VOUT1 + // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap + // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] + sendRequest.tx.addOutput( + TransactionOutput( + params, + tx, + Coin.ZERO, + ScriptBuilder.createOpReturnScript(memo.toByteArray()).program + ) + ) + // account for the size and possibly larger signatures when re-signed + val size = sendRequest.tx.bitcoinSerialize().size + sendRequest.tx.inputs.size + sendRequest.tx.outputs[0].value = swapTradeUIModel.amount.dash.toCoin() - + Coin.valueOf(size * Transaction.REFERENCE_DEFAULT_MIN_TX_FEE.value / 1000) + } else { + // Pass all change back to the VIN0 address in VOUT2 + val connectedOutput = sendRequest.tx.getInput(0).connectedOutput + ?: return ResponseResource.Failure( + MayaException("transaction input not connected"), false, 0, null ) - } - - if (swapTradeUIModel.maximum) { - // Include the memo as an OP_RETURN in VOUT1 - // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap - // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] - sendRequest.tx.addOutput( - TransactionOutput( - params, - tx, - Coin.ZERO, - ScriptBuilder.createOpReturnScript(memo.toByteArray()).program - ) - ) - // account for the size and possibly larger signatures when re-signed - val size = sendRequest.tx.bitcoinSerialize().size + sendRequest.tx.inputs.size - sendRequest.tx.outputs[0].value = swapTradeUIModel.amount.dash.toCoin() - - Coin.valueOf(size * Transaction.REFERENCE_DEFAULT_MIN_TX_FEE.value / 1000) - } else { - // Pass all change back to the VIN0 address in VOUT2 - val connectedOutput = sendRequest.tx.getInput(0).connectedOutput - ?: return ResponseResource.Failure( - MayaException("transaction input not connected"), - false, - 0, - null - ) - val scriptPubKey = connectedOutput.scriptPubKey - - // to replace output[2], we must clear all outputs and them back - // this is because Transaction.getOutputs returns an immutable list - val outputs = sendRequest.tx.outputs.map { it } - sendRequest.tx.clearOutputs() - for (i in outputs.indices) { - if (i != 2) { - sendRequest.tx.addOutput(outputs[i]) - } else { - sendRequest.tx.addOutput(outputs[i].value, scriptPubKey) - } + val scriptPubKey = connectedOutput.scriptPubKey + + // to replace output[2], we must clear all outputs and them back + // this is because Transaction.getOutputs returns an immutable list + val outputs = sendRequest.tx.outputs.map { it } + sendRequest.tx.clearOutputs() + for (i in outputs.indices) { + if (i != 2) { + sendRequest.tx.addOutput(outputs[i]) + } else { + sendRequest.tx.addOutput(outputs[i].value, scriptPubKey) } } + } - // remove all signatures since we changed the last output. - for (input in sendRequest.tx.inputs) { - input.clearScriptBytes() - } + // remove all signatures since we changed the last output. + for (input in sendRequest.tx.inputs) { + input.clearScriptBytes() + } - log.info("maya swap transaction: {}", sendRequest.tx) + log.info("maya swap transaction: {}", sendRequest.tx) - sendPaymentService.signTransaction(sendRequest) - log.info("maya swap transaction resigned: {}", sendRequest.tx) + sendPaymentService.signTransaction(sendRequest) + log.info("maya swap transaction resigned: {}", sendRequest.tx) - // check that vout3 is using vin0 - if (!swapTradeUIModel.maximum && ScriptPattern.isP2PKH(sendRequest.tx.outputs[2].scriptPubKey)) { - val input0 = sendRequest.tx.inputs[0] - if (sendRequest.tx.outputs[2].scriptPubKey != input0.connectedOutput?.scriptPubKey) { - return ResponseResource.Failure(MayaException("vout3 script != vin0"), false, 0, null) - } - } - // check the fee - val fee = sendRequest.tx.fee / sendRequest.tx.bitcoinSerialize().size * 1000 - if (fee < Transaction.DEFAULT_TX_FEE) { - return ResponseResource.Failure(MayaException("swap transaction fee too small"), false, 0, null) + // check that vout3 is using vin0 + if (!swapTradeUIModel.maximum && ScriptPattern.isP2PKH(sendRequest.tx.outputs[2].scriptPubKey)) { + val input0 = sendRequest.tx.inputs[0] + if (sendRequest.tx.outputs[2].scriptPubKey != input0.connectedOutput?.scriptPubKey) { + return ResponseResource.Failure(MayaException("vout3 script != vin0"), false, 0, null) } - - // Replace sendRequest.tx with a fresh Transaction before committing. - // wallet.completeTx() caches a TransactionConfidence (keyed to the txid at - // that moment) in Transaction.confidence. After we modify outputs and re-sign, - // the txid changes but the cached field is not updated — it still points to the - // stale confidence. Creating a new Transaction and moving the same input/output - // objects into it leaves confidence == null, so wallet.commitTx() will create - // the correct confidence for the final txid, keeping the TxConfidenceTable and - // any confidence listeners in sync. All transient state (connectedOutput, - // input.value, signatures) is preserved because we reuse the same objects. - val freshTx = Transaction(params) - sendRequest.tx.outputs.forEach { freshTx.addOutput(it) } - sendRequest.tx.inputs.forEach { freshTx.addInput(it) } - sendRequest.tx = freshTx - - // send the transaction - log.info("maya swap transaction: {}", sendRequest.tx.toStringHex()) - val sentTransaction = sendPaymentService.sendTransaction(sendRequest) - swapTradeUIModel.txid = sentTransaction.txId - return ResponseResource.Success(swapTradeUIModel) - } catch (e: InsufficientMoneyException) { - return ResponseResource.Failure(e, false, 0, e.message) } - } else { - return resultSwapTrade + // check the fee + val fee = sendRequest.tx.fee / sendRequest.tx.bitcoinSerialize().size * 1000 + if (fee < Transaction.DEFAULT_TX_FEE) { + return ResponseResource.Failure(MayaException("swap transaction fee too small"), false, 0, null) + } + + // Replace sendRequest.tx with a fresh Transaction before committing. + // wallet.completeTx() caches a TransactionConfidence (keyed to the txid at + // that moment) in Transaction.confidence. After we modify outputs and re-sign, + // the txid changes but the cached field is not updated — it still points to the + // stale confidence. Creating a new Transaction and moving the same input/output + // objects into it leaves confidence == null, so wallet.commitTx() will create + // the correct confidence for the final txid, keeping the TxConfidenceTable and + // any confidence listeners in sync. All transient state (connectedOutput, + // input.value, signatures) is preserved because we reuse the same objects. + val freshTx = Transaction(params) + sendRequest.tx.outputs.forEach { freshTx.addOutput(it) } + sendRequest.tx.inputs.forEach { freshTx.addInput(it) } + sendRequest.tx = freshTx + + // send the transaction + log.info("maya swap transaction: {}", sendRequest.tx.toStringHex()) + val sentTransaction = sendPaymentService.sendTransaction(sendRequest) + swapTradeUIModel.txid = sentTransaction.txId + return ResponseResource.Success(swapTradeUIModel) + } catch (e: InsufficientMoneyException) { + return ResponseResource.Failure(e, false, 0, e.message) } } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt new file mode 100644 index 0000000000..d79bc3f0b4 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.api + +import android.content.Intent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.util.toBigDecimal +import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.model.AccountDataUIModel +import org.dash.wallet.integrations.maya.model.InboundAddress +import org.dash.wallet.integrations.maya.model.PoolInfo +import org.dash.wallet.integrations.maya.model.SwapQuote +import org.dash.wallet.integrations.maya.model.SwapQuoteRequest +import org.dash.wallet.integrations.maya.model.SwapTradeUIModel +import java.math.BigDecimal + +/** + * Backend-agnostic surface for cross-chain swaps. + * + * Both the direct Maya integration ([MayaApiAggregator]) and the SwapKit aggregator + * ([org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator]) implement this + * interface so that ViewModels can be wired to either backend. + * + * Maya-shaped models ([PoolInfo], [SwapQuote], [InboundAddress], [SwapTradeUIModel]) + * are reused as the common DTO shape. SwapKit responses are mapped onto these on the + * provider side; only the fields the wallet UI consumes need to be populated. + */ +interface SwapProvider { + val poolInfoList: StateFlow> + val apiError: StateFlow + var notificationIntent: Intent? + var showNotificationOnResult: Boolean + + suspend fun reset() + + fun observePoolList(fiatExchangeRate: Fiat): Flow> + + /** + * Returns the list of vault/inbound addresses for every chain the provider supports. + * For Maya this is the live `/inbound_addresses` response. For SwapKit, vault + * addresses come back per-swap inside `/v3/swap`, so the implementation synthesises + * one entry per supported chain with `halted=false` (and the actual address blank). + */ + suspend fun getInboundAddresses(): List + + /** Indicative quote against a chain's example address — used to bootstrap the input screen. */ + suspend fun getDefaultSwapQuote(toAsset: String, value: Long = 10_0000_0000): SwapQuote? + + /** Indicative quote against a user-specified destination address. */ + suspend fun getDefaultSwapQuote(toAsset: String, destinationAddress: String, value: Long = 1_0000_0000): SwapQuote? + + /** + * Resolves a [SwapQuoteRequest] into a fully-specified [SwapTradeUIModel] with vault + * address + memo + fee, ready for the user to confirm. For SwapKit this calls + * `/v3/quote` followed by `/v3/swap`. + */ + suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource + + /** + * Commits a confirmed [SwapTradeUIModel]: refreshes the quote (if applicable), + * builds and broadcasts the DASH transaction. Both backends ultimately delegate + * to [MayaBlockchainApi] to construct the DASH tx — the only difference is which + * web API the refresh hits. + */ + suspend fun commitSwapTransaction( + tradeId: String, + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource + + /** Stub-friendly user-accounts probe; today only Maya returns a single placeholder. */ + suspend fun getUserAccounts(currency: String): List + + fun applyPoolPrices(pools: List, usdToFiat: Fiat) +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt index 98fdf84b62..94c1a63523 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt @@ -22,8 +22,12 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.dash.wallet.common.BuildConfig import org.dash.wallet.common.WalletDataProvider import org.dash.wallet.integrations.maya.api.CurrencyBeaconApi +import org.dash.wallet.integrations.maya.api.DispatchingSwapProvider import org.dash.wallet.integrations.maya.api.ExchangeRateApi import org.dash.wallet.integrations.maya.api.FiatExchangeRateAggregatedProvider import org.dash.wallet.integrations.maya.api.FiatExchangeRateProvider @@ -34,7 +38,13 @@ import org.dash.wallet.integrations.maya.api.MayaBlockchainApi import org.dash.wallet.integrations.maya.api.MayaBlockchainApiImpl import org.dash.wallet.integrations.maya.api.MayaEndpoint import org.dash.wallet.integrations.maya.api.RemoteDataSource +import org.dash.wallet.integrations.maya.api.SwapProvider +import org.dash.wallet.integrations.maya.swapkit.SwapKitAuthInterceptor +import org.dash.wallet.integrations.maya.swapkit.SwapKitConstants +import org.dash.wallet.integrations.maya.swapkit.SwapKitEndpoint import org.dash.wallet.integrations.maya.utils.MayaConstants +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton @Module @@ -73,6 +83,28 @@ abstract class MayaModule { val baseUrl = MayaConstants.FREE_CURRENCY_API_BASE_URL return remoteDataSource.buildApi(FreeCurrencyApi::class.java, baseUrl) } + + @Provides + @Singleton + fun provideSwapKitEndpoint(): SwapKitEndpoint { + val client = OkHttpClient.Builder() + .addInterceptor(SwapKitAuthInterceptor(SwapKitConstants.API_KEY)) + .also { builder -> + if (BuildConfig.DEBUG) { + val logging = HttpLoggingInterceptor() + logging.level = HttpLoggingInterceptor.Level.BODY + builder.addInterceptor(logging) + } + } + .build() + return Retrofit.Builder() + .baseUrl(SwapKitConstants.BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(SwapKitEndpoint::class.java) + } + } @Binds @@ -86,4 +118,14 @@ abstract class MayaModule { @Binds @Singleton abstract fun bindFiatExchangeRateApi(fiatApi: FiatExchangeRateAggregatedProvider): FiatExchangeRateProvider -} + + /** + * Single dispatch point for the cross-chain swap surface. The same singleton + * instance is also injectable as [DispatchingSwapProvider] for callers that + * need to switch the active backend at runtime (e.g. + * `BuyAndSellViewModel.setSwapBackend`). + */ + @Binds + @Singleton + abstract fun bindSwapProvider(impl: DispatchingSwapProvider): SwapProvider +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt index 74388fc938..d3fd52e473 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt @@ -58,6 +58,10 @@ fun getMayaErrorType(error: String): MayaErrorType { else -> MayaErrorType.QUOTE_ERROR } } + // SwapKit returns this when the sell amount is below the route's economic + // minimum — no provider can profitably fill the swap. Surface it the same + // way Maya's below-minimum error is surfaced. + "noRoutesFound" -> MayaErrorType.AMOUNT_TOO_LOW else -> MayaErrorType.UNKNOWN_ERROR } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapQuote.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapQuote.kt index 70792f24b5..027bcbad6c 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapQuote.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapQuote.kt @@ -28,11 +28,11 @@ data class SwapQuote( val fees: SwapFees, @SerializedName("inbound_address") val inboundAddress: String, @SerializedName("inbound_confirmation_blocks") val inboundConfirmationBlocks: Int, - @SerializedName("inbound_confirmation_seconds") val inboundConfirmationSeconds: Int, + @SerializedName("inbound_confirmation_seconds") val inboundConfirmationSeconds: Double, val memo: String, val notes: String, @SerializedName("outbound_delay_blocks") val outboundDelayBlocks: Int, - @SerializedName("outbound_delay_seconds") val outboundDelaySeconds: Int, + @SerializedName("outbound_delay_seconds") val outboundDelaySeconds: Double, @SerializedName("recommended_min_amount_in") val recommendedMinAmountIn: String, @SerializedName("slippage_bps") val slippageBps: Int, val warning: String, diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt index 3629473ea2..a551a8e48f 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt @@ -80,7 +80,9 @@ data class SwapTradeUIModel( var outputCurrencyName: String = "", var memo: String? = null, var txid: Sha256Hash = Sha256Hash.ZERO_HASH, - var expectedOutputAmount: BigDecimal = BigDecimal.ZERO + var expectedOutputAmount: BigDecimal = BigDecimal.ZERO, + val routeName: String? = "maya-default", + val availableRoutes: List = listOf() ) : Parcelable { @IgnoredOnParcel val inputCurrency = amount.dashCode diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt index 1c4f13dd47..2cc5dbdb7e 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt @@ -26,10 +26,27 @@ import org.dash.wallet.common.payments.parsers.PaymentParsers import org.dash.wallet.integrations.maya.R import org.dash.wallet.integrations.maya.payments.parsers.Bech32PaymentIntentParser import org.dash.wallet.integrations.maya.payments.parsers.BitcoinPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.CardanoAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.CardanoPaymentIntentParser import org.dash.wallet.integrations.maya.payments.parsers.EthereumPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.NearAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.NearPaymentIntentParser import org.dash.wallet.integrations.maya.payments.parsers.RuneAddressParser import org.dash.wallet.integrations.maya.payments.parsers.RunePaymentIntentProcessor +import org.dash.wallet.integrations.maya.payments.parsers.SimpleBase58PaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.SolanaAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.SolanaPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.StarknetAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.StarknetPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.SuiAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.SuiPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.TonAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.TonPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.TronAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.TronPaymentIntentParser import org.dash.wallet.integrations.maya.payments.parsers.XrdPaymentIntentParser +import org.dash.wallet.integrations.maya.payments.parsers.XrpAddressParser +import org.dash.wallet.integrations.maya.payments.parsers.XrpPaymentIntentParser import org.dash.wallet.integrations.maya.payments.parsers.ZcashAddressParser import org.dash.wallet.integrations.maya.payments.parsers.ZcashPaymentIntentParser import java.math.BigDecimal @@ -161,6 +178,23 @@ open class MayaMayaTokenCryptoCurrency : MayaBitcoinCryptoCurrency() { override val nameId: Int = R.string.cryptocurrency_maya_network } +open class MayaCacaoCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "CACAO" + override val name: String = "Maya Protocol" + override val asset: String = "MAYA.CACAO" + override val exampleAddress: String = "maya1x9jj85ugrpf8j0nhq9p7c4qjn9a2ufnhmlvt5e" + override val paymentIntentParser: PaymentIntentParser = Bech32PaymentIntentParser( + "CACAO", + "maya", + "maya", + 38, + "MAYA.CACAO" + ) + override val addressParser: AddressParser = Bech32AddressParser("maya", 38, null) + override val codeId: Int = R.string.cryptocurrency_cacao_code + override val nameId: Int = R.string.cryptocurrency_cacao_network +} + open class MayaZcashCryptoCurrency : MayaBitcoinCryptoCurrency() { override val code: String = "ZEC" override val name: String = "Zcash" @@ -187,6 +221,328 @@ open class MayaRadixCryptoCurrency : MayaBitcoinCryptoCurrency() { override val nameId: Int = R.string.cryptocurrency_xrd_network } +// --------------------------------------------------------------------------- +// EVM L2 / sidechain native coin classes — share the Ethereum address format +// and 1e9 GWEI scaling. The asset string varies per chain (e.g. BASE.ETH). +// --------------------------------------------------------------------------- + +open class MayaBaseCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "ETH" + override val name: String = "Ethereum" + override val asset: String = "BASE.ETH" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("ethereum", "BASE.ETH") + override val codeId: Int = R.string.cryptocurrency_ethereum_code + override val nameId: Int = R.string.cryptocurrency_ethereum_base_network +} + +open class MayaOptimismCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "ETH" + override val name: String = "Ethereum" + override val asset: String = "OP.ETH" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("ethereum", "OP.ETH") + override val codeId: Int = R.string.cryptocurrency_ethereum_code + override val nameId: Int = R.string.cryptocurrency_ethereum_optimism_network +} + +open class MayaAvalancheCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "AVAX" + override val name: String = "Avalanche" + override val asset: String = "AVAX.AVAX" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("avax", "AVAX.AVAX") + override val codeId: Int = R.string.cryptocurrency_avax_code + override val nameId: Int = R.string.cryptocurrency_avax_network +} + +open class MayaBnbSmartChainCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "BNB" + override val name: String = "BNB" + override val asset: String = "BSC.BNB" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("bnb", "BSC.BNB") + override val codeId: Int = R.string.cryptocurrency_bnb_code + override val nameId: Int = R.string.cryptocurrency_bnb_network +} + +open class MayaBeraCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "BERA" + override val name: String = "Berachain" + override val asset: String = "BERA.BERA" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("bera", "BERA.BERA") + override val codeId: Int = R.string.cryptocurrency_bera_code + override val nameId: Int = R.string.cryptocurrency_bera_network +} + +open class MayaMonadCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "MON" + override val name: String = "Monad" + override val asset: String = "MONAD.MON" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("monad", "MONAD.MON") + override val codeId: Int = R.string.cryptocurrency_mon_code + override val nameId: Int = R.string.cryptocurrency_mon_network +} + +open class MayaPolygonCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "POL" + override val name: String = "POL" + override val asset: String = "POL.POL" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("pol", "POL.POL") + override val codeId: Int = R.string.cryptocurrency_pol_code + override val nameId: Int = R.string.cryptocurrency_pol_network +} + +open class MayaXLayerCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "OKB" + override val name: String = "OKB" + override val asset: String = "XLAYER.OKB" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("okb", "XLAYER.OKB") + override val codeId: Int = R.string.cryptocurrency_okb_code + override val nameId: Int = R.string.cryptocurrency_okb_network +} + +open class MayaGnosisXdaiCryptoCurrency : MayaEthereumCryptoCurrency() { + override val code: String = "XDAI" + override val name: String = "xDAI" + override val asset: String = "GNO.XDAI" + override val exampleAddress: String = "0x51a1449b3B6D635EddeC781cD47a99221712De97" + override val paymentIntentParser: PaymentIntentParser = EthereumPaymentIntentParser("xdai", "GNO.XDAI") + override val codeId: Int = R.string.cryptocurrency_xdai_code + override val nameId: Int = R.string.cryptocurrency_xdai_network +} + +// --------------------------------------------------------------------------- +// Bitcoin-family L1 native classes +// --------------------------------------------------------------------------- + +open class MayaBitcoinCashCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "BCH" + override val name: String = "Bitcoin Cash" + override val asset: String = "BCH.BCH" + override val exampleAddress: String = "qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a" + override val paymentIntentParser: PaymentIntentParser = SimpleBase58PaymentIntentParser( + "BCH", + "bitcoincash", + "BCH.BCH", + "(bitcoincash:)?[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{42,55}|[13][1-9A-HJ-NP-Za-km-z]{25,34}" + ) + override val addressParser: AddressParser = AddressParser( + "(bitcoincash:)?[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{42,55}|[13][1-9A-HJ-NP-Za-km-z]{25,34}", + null + ) + override val codeId: Int = R.string.cryptocurrency_bch_code + override val nameId: Int = R.string.cryptocurrency_bch_network +} + +open class MayaLitecoinCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "LTC" + override val name: String = "Litecoin" + override val asset: String = "LTC.LTC" + override val exampleAddress: String = "ltc1qd5wm03t5kcdupjuyq5jffpuacnaqahvfsdu8smf8z0u0pqdqpatqsdrn8h" + override val paymentIntentParser: PaymentIntentParser = SimpleBase58PaymentIntentParser( + "LTC", + "litecoin", + "LTC.LTC", + "(ltc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,71})|([LM3][1-9A-HJ-NP-Za-km-z]{26,33})" + ) + override val addressParser: AddressParser = AddressParser( + "(ltc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,71})|([LM3][1-9A-HJ-NP-Za-km-z]{26,33})", + null + ) + override val codeId: Int = R.string.cryptocurrency_ltc_code + override val nameId: Int = R.string.cryptocurrency_ltc_network +} + +open class MayaDogecoinCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "DOGE" + override val name: String = "Dogecoin" + override val asset: String = "DOGE.DOGE" + override val exampleAddress: String = "DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L" + override val paymentIntentParser: PaymentIntentParser = SimpleBase58PaymentIntentParser( + "DOGE", + "dogecoin", + "DOGE.DOGE", + "[DA9][1-9A-HJ-NP-Za-km-z]{32,33}" + ) + override val addressParser: AddressParser = AddressParser( + "[DA9][1-9A-HJ-NP-Za-km-z]{32,33}", + null + ) + override val codeId: Int = R.string.cryptocurrency_doge_code + override val nameId: Int = R.string.cryptocurrency_doge_network +} + +// --------------------------------------------------------------------------- +// Other L1 native chains (custom parsers) +// --------------------------------------------------------------------------- + +open class MayaCardanoCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "ADA" + override val name: String = "Cardano" + override val asset: String = "ADA.ADA" + override val exampleAddress: String = + "addr1q9c8e2wjwj4uxsmrk2lqkkpqalwzvxgyx7uxjkfeg7xc3xa07c6qzwrcfh2x4f4z4uyez5lpd07v3jkh3ttn0xc2x7qspewtaa" + override val paymentIntentParser: PaymentIntentParser = CardanoPaymentIntentParser() + override val addressParser: AddressParser = CardanoAddressParser() + override val codeId: Int = R.string.cryptocurrency_ada_code + override val nameId: Int = R.string.cryptocurrency_ada_network +} + +open class MayaSolanaCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "SOL" + override val name: String = "Solana" + override val asset: String = "SOL.SOL" + override val exampleAddress: String = "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK" + override val paymentIntentParser: PaymentIntentParser = SolanaPaymentIntentParser() + override val addressParser: AddressParser = SolanaAddressParser() + override val codeId: Int = R.string.cryptocurrency_sol_code + override val nameId: Int = R.string.cryptocurrency_sol_network +} + +open class MayaNearCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "NEAR" + override val name: String = "NEAR Protocol" + override val asset: String = "NEAR.NEAR" + override val exampleAddress: String = "alice.near" + override val paymentIntentParser: PaymentIntentParser = NearPaymentIntentParser() + override val addressParser: AddressParser = NearAddressParser() + override val codeId: Int = R.string.cryptocurrency_near_code + override val nameId: Int = R.string.cryptocurrency_near_network +} + +open class MayaTronCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "TRX" + override val name: String = "TRON" + override val asset: String = "TRON.TRX" + override val exampleAddress: String = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + override val paymentIntentParser: PaymentIntentParser = TronPaymentIntentParser() + override val addressParser: AddressParser = TronAddressParser() + override val codeId: Int = R.string.cryptocurrency_trx_code + override val nameId: Int = R.string.cryptocurrency_trx_network +} + +open class MayaXrpCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "XRP" + override val name: String = "XRP" + override val asset: String = "XRP.XRP" + override val exampleAddress: String = "rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh" + override val paymentIntentParser: PaymentIntentParser = XrpPaymentIntentParser() + override val addressParser: AddressParser = XrpAddressParser() + override val codeId: Int = R.string.cryptocurrency_xrp_code + override val nameId: Int = R.string.cryptocurrency_xrp_network +} + +open class MayaTonCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "TON" + override val name: String = "Toncoin" + override val asset: String = "TON.TON" + override val exampleAddress: String = "EQDrjaLahLkMB-hMCmkzOyBuHJ139ZUYmPHu6RRBKnbdLIYI" + override val paymentIntentParser: PaymentIntentParser = TonPaymentIntentParser() + override val addressParser: AddressParser = TonAddressParser() + override val codeId: Int = R.string.cryptocurrency_ton_code + override val nameId: Int = R.string.cryptocurrency_ton_network +} + +open class MayaSuiCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "SUI" + override val name: String = "Sui" + override val asset: String = "SUI.SUI" + override val exampleAddress: String = + "0xd1b72982e40348d069bb1ff701e634c117bb5f741f44dff91e472d3b01461e55" + override val paymentIntentParser: PaymentIntentParser = SuiPaymentIntentParser() + override val addressParser: AddressParser = SuiAddressParser() + override val codeId: Int = R.string.cryptocurrency_sui_code + override val nameId: Int = R.string.cryptocurrency_sui_network +} + +open class MayaStarknetCryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "STRK" + override val name: String = "Starknet" + override val asset: String = "STRK.STRK" + override val exampleAddress: String = + "0x05dcaeae5fde9a4cdb44ea21cba29ad9e6e0c1e9ae7e7e2b6b2f0f6e2e3e4e5e6" + override val paymentIntentParser: PaymentIntentParser = StarknetPaymentIntentParser() + override val addressParser: AddressParser = StarknetAddressParser() + override val codeId: Int = R.string.cryptocurrency_strk_code + override val nameId: Int = R.string.cryptocurrency_strk_network +} + +// --------------------------------------------------------------------------- +// Token wrapper classes for chains where many SwapKit identifiers exist +// (one wrapper per chain reuses the chain's address parser; the per-token +// payment intent parser carries the short asset alias used in Maya memos). +// --------------------------------------------------------------------------- + +class MayaSolanaTokenCryptoCurrency( + override val code: String, + override val name: String, + override val asset: String, + shortAsset: String, + override val codeId: Int, + override val nameId: Int +) : MayaSolanaCryptoCurrency() { + override val paymentIntentParser: PaymentIntentParser = + SolanaPaymentIntentParser(code, asset, shortAsset) + override val addressParser: AddressParser = SolanaAddressParser() +} + +class MayaNearTokenCryptoCurrency( + override val code: String, + override val name: String, + override val asset: String, + shortAsset: String, + override val codeId: Int, + override val nameId: Int +) : MayaNearCryptoCurrency() { + override val paymentIntentParser: PaymentIntentParser = + NearPaymentIntentParser(code, asset, shortAsset) + override val addressParser: AddressParser = NearAddressParser() +} + +class MayaTonTokenCryptoCurrency( + override val code: String, + override val name: String, + override val asset: String, + shortAsset: String, + override val codeId: Int, + override val nameId: Int +) : MayaTonCryptoCurrency() { + override val paymentIntentParser: PaymentIntentParser = + TonPaymentIntentParser(code, asset, shortAsset) + override val addressParser: AddressParser = TonAddressParser() +} + +class MayaTronTokenCryptoCurrency( + override val code: String, + override val name: String, + override val asset: String, + shortAsset: String, + override val codeId: Int, + override val nameId: Int +) : MayaTronCryptoCurrency() { + override val paymentIntentParser: PaymentIntentParser = + TronPaymentIntentParser(code, asset, shortAsset) + override val addressParser: AddressParser = TronAddressParser() +} + +class MayaSuiTokenCryptoCurrency( + override val code: String, + override val name: String, + override val asset: String, + shortAsset: String, + override val codeId: Int, + override val nameId: Int +) : MayaSuiCryptoCurrency() { + override val paymentIntentParser: PaymentIntentParser = + SuiPaymentIntentParser(code, asset, shortAsset) + override val addressParser: AddressParser = SuiAddressParser() +} + object MayaCurrencyList { private val currencyMap: Map init { @@ -346,6 +702,122 @@ object MayaCurrencyList { R.string.cryptocurrency_tether_arbitrum_network ), + // ----- ETH chain new tokens ----- + MayaEthereumTokenCryptoCurrency( + "ADI", + "ADI", + "ETH.ADI-0X8B1484D57ABBE239BB280661377363B03C89CAEA", + EthereumPaymentIntentParser("adi", "ETH.ADI-9CAEA"), + R.string.cryptocurrency_adi_code, + R.string.cryptocurrency_adi_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "AURORA", + "Aurora", + "ETH.AURORA-0XAAAAAA20D9E0E2461697782EF11675F668207961", + EthereumPaymentIntentParser("aurora", "ETH.AURORA-07961"), + R.string.cryptocurrency_aurora_code, + R.string.cryptocurrency_aurora_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "CBBTC", + "Coinbase Wrapped BTC", + "ETH.CBBTC-0XCBB7C0000AB88B473B1F5AFD9EF808440EED33BF", + EthereumPaymentIntentParser("cbbtc", "ETH.CBBTC-D33BF"), + R.string.cryptocurrency_cbbtc_code, + R.string.cryptocurrency_cbbtc_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "DAI", + "Dai", + "ETH.DAI-0X6B175474E89094C44DA98B954EEDEAC495271D0F", + EthereumPaymentIntentParser("dai", "ETH.DAI-71D0F"), + R.string.cryptocurrency_dai_code, + R.string.cryptocurrency_dai_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "MOG", + "Mog Coin", + "ETH.MOG-0XAAEE1A9723AADB7AFA2810263653A34BA2C21C7A", + EthereumPaymentIntentParser("mog", "ETH.MOG-21C7A"), + R.string.cryptocurrency_mog_code, + R.string.cryptocurrency_mog_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "SAFE", + "Safe", + "ETH.SAFE-0X5AFE3855358E112B5647B952709E6165E1C1EEEE", + EthereumPaymentIntentParser("safe", "ETH.SAFE-1EEEE"), + R.string.cryptocurrency_safe_code, + R.string.cryptocurrency_safe_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "SHIB", + "Shiba Inu", + "ETH.SHIB-0X95AD61B0A150D79219DCF64E1E6CC01F0B64C4CE", + EthereumPaymentIntentParser("shib", "ETH.SHIB-4C4CE"), + R.string.cryptocurrency_shib_code, + R.string.cryptocurrency_shib_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "TURBO", + "Turbo", + "ETH.TURBO-0XA35923162C49CF95E6BF26623385EB431AD920D3", + EthereumPaymentIntentParser("turbo", "ETH.TURBO-920D3"), + R.string.cryptocurrency_turbo_code, + R.string.cryptocurrency_turbo_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "USD1", + "USD1", + "ETH.USD1-0X8D0D000EE44948FC98C9B98A4FA4921476F08B0D", + EthereumPaymentIntentParser("usd1", "ETH.USD1-08B0D"), + R.string.cryptocurrency_usd1_code, + R.string.cryptocurrency_usd1_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "USDF", + "Falcon USD", + "ETH.USDF-0XFA2B947EEC368F42195F24F36D2AF29F7C24CEC2", + EthereumPaymentIntentParser("usdf", "ETH.USDF-4CEC2"), + R.string.cryptocurrency_usdf_code, + R.string.cryptocurrency_usdf_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "WBTC", + "Wrapped Bitcoin", + "ETH.WBTC-0X2260FAC5E5542A773AA44FBCFEDF7C193BC2C599", + EthereumPaymentIntentParser("wbtc", "ETH.WBTC-2C599"), + R.string.cryptocurrency_wbtc_code, + R.string.cryptocurrency_wbtc_ethereum_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "WETH", + "ETH.WETH-0XC02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2", + EthereumPaymentIntentParser("weth", "ETH.WETH-56CC2"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_ethereum_network + ), + + // ----- ARB chain new tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDT0", + "USDT0", + "ARB.USDT0-0XFD086BC7CD5C481DCC9C85EBE478A1C0B69FCBB9", + EthereumPaymentIntentParser("usdt0", "ARB.USDT0-FCBB9"), + R.string.cryptocurrency_usdt0_code, + R.string.cryptocurrency_usdt0_arbitrum_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "Arbitrum Bridged WETH", + "ARB.WETH-0X82AF49447D8A07E3BD95BD0D56F35241523FBAB1", + EthereumPaymentIntentParser("weth", "ARB.WETH-FBAB1"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_arbitrum_network + ), + MayaKujiraCryptoCurrency(), MayaKujiraTokenCryptoCurrency( "USK", @@ -358,7 +830,598 @@ object MayaCurrencyList { MayaRuneCryptoCurrency(), MayaZcashCryptoCurrency(), MayaRadixCryptoCurrency(), - MayaMayaTokenCryptoCurrency() + MayaMayaTokenCryptoCurrency(), + MayaCacaoCryptoCurrency(), + + // ----- New L1 native coins (BTC family) ----- + MayaBitcoinCashCryptoCurrency(), + MayaLitecoinCryptoCurrency(), + MayaDogecoinCryptoCurrency(), + + // ----- New L1 native coins (other) ----- + MayaCardanoCryptoCurrency(), + MayaSolanaCryptoCurrency(), + MayaNearCryptoCurrency(), + MayaTronCryptoCurrency(), + MayaXrpCryptoCurrency(), + MayaTonCryptoCurrency(), + MayaSuiCryptoCurrency(), + MayaStarknetCryptoCurrency(), + + // ----- EVM-style native coins on L2s / sidechains ----- + MayaBaseCryptoCurrency(), + MayaOptimismCryptoCurrency(), + MayaAvalancheCryptoCurrency(), + MayaBnbSmartChainCryptoCurrency(), + MayaBeraCryptoCurrency(), + MayaMonadCryptoCurrency(), + MayaPolygonCryptoCurrency(), + MayaXLayerCryptoCurrency(), + MayaGnosisXdaiCryptoCurrency(), + + // ----- BASE chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "CBBTC", + "Coinbase Wrapped BTC", + "BASE.CBBTC-0XCBB7C0000AB88B473B1F5AFD9EF808440EED33BF", + EthereumPaymentIntentParser("cbbtc", "BASE.CBBTC-D33BF"), + R.string.cryptocurrency_cbbtc_code, + R.string.cryptocurrency_cbbtc_base_network + ), + MayaEthereumTokenCryptoCurrency( + "CFI", + "ConsumerFi Protocol", + "BASE.CFI-0X0382E3FEE4A420BD446367D468A6F00225853420", + EthereumPaymentIntentParser("cfi", "BASE.CFI-53420"), + R.string.cryptocurrency_cfi_code, + R.string.cryptocurrency_cfi_base_network + ), + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "BASE.USDC-0X833589FCD6EDB6E08F4C7C32D4F71B54BDA02913", + EthereumPaymentIntentParser("usdc", "BASE.USDC-02913"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_base_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "L2 Standard Bridged WETH", + "BASE.WETH-0X4200000000000000000000000000000000000006", + EthereumPaymentIntentParser("weth", "BASE.WETH-00006"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_base_network + ), + + // ----- OP chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "OP", + "OP", + "OP.OP-0X4200000000000000000000000000000000000042", + EthereumPaymentIntentParser("op", "OP.OP-00042"), + R.string.cryptocurrency_op_code, + R.string.cryptocurrency_op_optimism_network + ), + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "OP.USDC-0X0B2C639C533813F4AA9D7837CAF62653D097FF85", + EthereumPaymentIntentParser("usdc", "OP.USDC-7FF85"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_optimism_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT", + "Tether", + "OP.USDT-0X94B008AA00579C1307B0EF2C499AD98A8CE58E58", + EthereumPaymentIntentParser("usdt", "OP.USDT-58E58"), + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_optimism_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "WETH", + "OP.WETH-0X4200000000000000000000000000000000000006", + EthereumPaymentIntentParser("weth", "OP.WETH-00006"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_optimism_network + ), + + // ----- AVAX chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "AVAX.USDC-0XB97EF9EF8734C71904D8002F8B6BC66DD9C48A6E", + EthereumPaymentIntentParser("usdc", "AVAX.USDC-48A6E"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_avalanche_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT", + "Tether", + "AVAX.USDT-0X9702230A8EA53601F5CD2DC00FDBC13D4DF4A8C7", + EthereumPaymentIntentParser("usdt", "AVAX.USDT-4A8C7"), + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_avalanche_network + ), + + // ----- BSC chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "ASTER", + "Aster", + "BSC.ASTER-0X000AE314E2A2172A039B26378814C252734F556A", + EthereumPaymentIntentParser("aster", "BSC.ASTER-F556A"), + R.string.cryptocurrency_aster_code, + R.string.cryptocurrency_aster_bsc_network + ), + MayaEthereumTokenCryptoCurrency( + "NEAR", + "Binance-Peg NEAR Protocol", + "BSC.NEAR-0X1FA4A73A3F0133F0025378AF00236F3ABDEE5D63", + EthereumPaymentIntentParser("near", "BSC.NEAR-E5D63"), + R.string.cryptocurrency_near_code, + R.string.cryptocurrency_near_bsc_network + ), + MayaEthereumTokenCryptoCurrency( + "RHEA", + "RHEA", + "BSC.RHEA-0X4C067DE26475E1CEFEE8B8D1F6E2266B33A2372E", + EthereumPaymentIntentParser("rhea", "BSC.RHEA-2372E"), + R.string.cryptocurrency_rhea_code, + R.string.cryptocurrency_rhea_bsc_network + ), + MayaEthereumTokenCryptoCurrency( + "USDC", + "Binance Bridged USDC", + "BSC.USDC-0X8AC76A51CC950D9822D68B83FE1AD97B32CD580D", + EthereumPaymentIntentParser("usdc", "BSC.USDC-D580D"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_bsc_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT", + "USDT", + "BSC.USDT-0X55D398326F99059FF775485246999027B3197955", + EthereumPaymentIntentParser("usdt", "BSC.USDT-97955"), + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_bsc_network + ), + + // ----- BERA chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDT0", + "USDT0", + "BERA.USDT0-0X779DED0C9E1022225F8E0630B35A9B54BE713736", + EthereumPaymentIntentParser("usdt0", "BERA.USDT0-13736"), + R.string.cryptocurrency_usdt0_code, + R.string.cryptocurrency_usdt0_bera_network + ), + + // ----- POL chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "POL.USDC-0X3C499C542CEF5E3811E1192CE70D8CC03D5C3359", + EthereumPaymentIntentParser("usdc", "POL.USDC-C3359"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_polygon_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT", + "Tether", + "POL.USDT-0XC2132D05D31C914A87C6611C10748AEB04B58E8F", + EthereumPaymentIntentParser("usdt", "POL.USDT-58E8F"), + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_polygon_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "Polygon PoS Bridged WETH", + "POL.WETH-0X7CEB23FD6BC0ADD59E62AC25578270CFF1B9F619", + EthereumPaymentIntentParser("weth", "POL.WETH-9F619"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_polygon_network + ), + + // ----- MONAD chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "MONAD.USDC-0X754704BC059F8C67012FED69BC8A327A5AAFB603", + EthereumPaymentIntentParser("usdc", "MONAD.USDC-FB603"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_monad_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT0", + "USDT0", + "MONAD.USDT0-0XE7CD86E13AC4309349F30B3435A9D337750FC82D", + EthereumPaymentIntentParser("usdt0", "MONAD.USDT0-FC82D"), + R.string.cryptocurrency_usdt0_code, + R.string.cryptocurrency_usdt0_monad_network + ), + + // ----- XLAYER chain tokens ----- + MayaEthereumTokenCryptoCurrency( + "USDC", + "USD Coin", + "XLAYER.USDC-0X74B7F16337B8972027F6196A17A631AC6DE26D22", + EthereumPaymentIntentParser("usdc", "XLAYER.USDC-26D22"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_xlayer_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT0", + "USDT0", + "XLAYER.USDT0-0X779DED0C9E1022225F8E0630B35A9B54BE713736", + EthereumPaymentIntentParser("usdt0", "XLAYER.USDT0-13736"), + R.string.cryptocurrency_usdt0_code, + R.string.cryptocurrency_usdt0_xlayer_network + ), + + // ----- GNO (Gnosis chain) tokens ----- + MayaEthereumTokenCryptoCurrency( + "COW", + "COW", + "GNO.COW-0X177127622C4A00F3D409B75571E12CB3C8973D3C", + EthereumPaymentIntentParser("cow", "GNO.COW-73D3C"), + R.string.cryptocurrency_cow_code, + R.string.cryptocurrency_cow_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "EURE", + "EURe", + "GNO.EURE-0X420CA0F9B9B604CE0FD9C18EF134C705E5FA3430", + EthereumPaymentIntentParser("eure", "GNO.EURE-A3430"), + R.string.cryptocurrency_eure_code, + R.string.cryptocurrency_eure_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "GNO", + "GNO", + "GNO.GNO-0X9C58BACC331C9AA871AFD802DB6379A98E80CEDB", + EthereumPaymentIntentParser("gno", "GNO.GNO-0CEDB"), + R.string.cryptocurrency_gno_code, + R.string.cryptocurrency_gno_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "SAFE", + "SAFE", + "GNO.SAFE-0X4D18815D14FE5C3304E87B3FA18318BAA5C23820", + EthereumPaymentIntentParser("safe", "GNO.SAFE-23820"), + R.string.cryptocurrency_safe_code, + R.string.cryptocurrency_safe_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "USDC", + "USDC", + "GNO.USDC-0X2A22F9C3B484C3629090FEED35F17FF8F88F76F0", + EthereumPaymentIntentParser("usdc", "GNO.USDC-F76F0"), + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "USDT", + "USDT", + "GNO.USDT-0X4ECABA5870353805A9F068101A40E0F32ED605C6", + EthereumPaymentIntentParser("usdt", "GNO.USDT-605C6"), + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_gnosis_network + ), + MayaEthereumTokenCryptoCurrency( + "WETH", + "WETH", + "GNO.WETH-0X6A023CCD1FF6F2045C3309768EAD9E68F978F6E1", + EthereumPaymentIntentParser("weth", "GNO.WETH-F6E1F"), + R.string.cryptocurrency_weth_code, + R.string.cryptocurrency_weth_gnosis_network + ), + + // ----- SOL chain tokens ----- + MayaSolanaTokenCryptoCurrency( + "WIF", + "dogwifhat", + "SOL.\$WIF-EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", + "SOL.WIF-zcjm", + R.string.cryptocurrency_wif_code, + R.string.cryptocurrency_wif_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "PENGU", + "Pudgy Penguins", + "SOL.PENGU-2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv", + "SOL.PENGU-uauv", + R.string.cryptocurrency_pengu_code, + R.string.cryptocurrency_pengu_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "SPX", + "SPX6900", + "SOL.SPX-J3NKxxXZcnNiMjKw9hYb2K4LUxgwB6t1FtPtQVsv3KFr", + "SOL.SPX-3KFr", + R.string.cryptocurrency_spx_code, + R.string.cryptocurrency_spx_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "TRUMP", + "Official Trump", + "SOL.TRUMP-6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "SOL.TRUMP-GiPN", + R.string.cryptocurrency_trump_code, + R.string.cryptocurrency_trump_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "TURBO", + "Turbo", + "SOL.TURBO-2Dyzu65QA9zdX1UeE7Gx71k7fiwyUK6sZdrvJ7auq5wm", + "SOL.TURBO-q5wm", + R.string.cryptocurrency_turbo_code, + R.string.cryptocurrency_turbo_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "USDC", + "USDC", + "SOL.USDC-EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "SOL.USDC-Dt1v", + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "USDT", + "Tether", + "SOL.USDT-Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "SOL.USDT-wNYB", + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "ZEC", + "OmniBridge Bridged Zcash", + "SOL.ZEC-A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + "SOL.ZEC-QXaS", + R.string.cryptocurrency_zec_code, + R.string.cryptocurrency_zec_solana_network + ), + MayaSolanaTokenCryptoCurrency( + "xBTC", + "OKX Wrapped BTC", + "SOL.xBTC-CtzPWv73Sn1dMGVU3ZtLv9yWSyUAanBni19YWDaznnkn", + "SOL.xBTC-nnkn", + R.string.cryptocurrency_xbtc_code, + R.string.cryptocurrency_xbtc_solana_network + ), + + // ----- NEAR chain tokens ----- + MayaNearTokenCryptoCurrency( + "AURORA", + "AURORA", + "NEAR.AURORA-aaaaaa20d9e0e2461697782ef11675f668207961.factory.bridge.near", + "NEAR.AURORA", + R.string.cryptocurrency_aurora_code, + R.string.cryptocurrency_aurora_near_network + ), + MayaNearTokenCryptoCurrency( + "BTC", + "BTC", + "NEAR.BTC-nbtc.bridge.near", + "NEAR.BTC", + R.string.cryptocurrency_bitcoin_code, + R.string.cryptocurrency_bitcoin_near_network + ), + MayaNearTokenCryptoCurrency( + "CFI", + "CFI", + "NEAR.CFI-cfi.consumer-fi.near", + "NEAR.CFI", + R.string.cryptocurrency_cfi_code, + R.string.cryptocurrency_cfi_near_network + ), + MayaNearTokenCryptoCurrency( + "ETH", + "ETH", + "NEAR.ETH-eth.bridge.near", + "NEAR.ETH", + R.string.cryptocurrency_ethereum_code, + R.string.cryptocurrency_ethereum_near_network + ), + MayaNearTokenCryptoCurrency( + "FRAX", + "FRAX", + "NEAR.FRAX-853d955acef822db058eb8505911ed77f175b99e.factory.bridge.near", + "NEAR.FRAX", + R.string.cryptocurrency_frax_code, + R.string.cryptocurrency_frax_near_network + ), + MayaNearTokenCryptoCurrency( + "ITLX", + "Intellex", + "NEAR.ITLX-itlx.intellex_xyz.near", + "NEAR.ITLX", + R.string.cryptocurrency_itlx_code, + R.string.cryptocurrency_itlx_near_network + ), + MayaNearTokenCryptoCurrency( + "JAMBO", + "JAMBO", + "NEAR.JAMBO-jambo-1679.meme-cooking.near", + "NEAR.JAMBO", + R.string.cryptocurrency_jambo_code, + R.string.cryptocurrency_jambo_near_network + ), + MayaNearTokenCryptoCurrency( + "NOEAR", + "NOEAR", + "NEAR.NOEAR-noear-324.meme-cooking.near", + "NEAR.NOEAR", + R.string.cryptocurrency_noear_code, + R.string.cryptocurrency_noear_near_network + ), + MayaNearTokenCryptoCurrency( + "NPRO", + "NPRO", + "NEAR.NPRO-npro.nearmobile.near", + "NEAR.NPRO", + R.string.cryptocurrency_npro_code, + R.string.cryptocurrency_npro_near_network + ), + MayaNearTokenCryptoCurrency( + "NearKat", + "NearKat", + "NEAR.NearKat-kat.token0.near", + "NEAR.NearKat", + R.string.cryptocurrency_nearkat_code, + R.string.cryptocurrency_nearkat_near_network + ), + MayaNearTokenCryptoCurrency( + "PUBLIC", + "PublicAI", + "NEAR.PUBLIC-token.publicailab.near", + "NEAR.PUBLIC", + R.string.cryptocurrency_public_code, + R.string.cryptocurrency_public_near_network + ), + MayaNearTokenCryptoCurrency( + "PURGE", + "PURGE", + "NEAR.PURGE-purge-558.meme-cooking.near", + "NEAR.PURGE", + R.string.cryptocurrency_purge_code, + R.string.cryptocurrency_purge_near_network + ), + MayaNearTokenCryptoCurrency( + "RHEA", + "RHEA", + "NEAR.RHEA-token.rhealab.near", + "NEAR.RHEA", + R.string.cryptocurrency_rhea_code, + R.string.cryptocurrency_rhea_near_network + ), + MayaNearTokenCryptoCurrency( + "SHITZU", + "Shitzu", + "NEAR.SHITZU-token.0xshitzu.near", + "NEAR.SHITZU", + R.string.cryptocurrency_shitzu_code, + R.string.cryptocurrency_shitzu_near_network + ), + MayaNearTokenCryptoCurrency( + "STJACK", + "STJACK", + "NEAR.STJACK-stjack.tkn.primitives.near", + "NEAR.STJACK", + R.string.cryptocurrency_stjack_code, + R.string.cryptocurrency_stjack_near_network + ), + MayaNearTokenCryptoCurrency( + "SWEAT", + "SWEAT", + "NEAR.SWEAT-token.sweat", + "NEAR.SWEAT", + R.string.cryptocurrency_sweat_code, + R.string.cryptocurrency_sweat_near_network + ), + MayaNearTokenCryptoCurrency( + "TURBO", + "TURBO", + "NEAR.TURBO-a35923162c49cf95e6bf26623385eb431ad920d3.factory.bridge.near", + "NEAR.TURBO", + R.string.cryptocurrency_turbo_code, + R.string.cryptocurrency_turbo_near_network + ), + MayaNearTokenCryptoCurrency( + "USDC", + "USDC", + "NEAR.USDC-17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "NEAR.USDC", + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_near_network + ), + MayaNearTokenCryptoCurrency( + "USDT", + "USDT", + "NEAR.USDT-usdt.tether-token.near", + "NEAR.USDT", + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_near_network + ), + MayaNearTokenCryptoCurrency( + "ZEC", + "ZEC", + "NEAR.ZEC-zec.omft.near", + "NEAR.ZEC", + R.string.cryptocurrency_zec_code, + R.string.cryptocurrency_zec_near_network + ), + MayaNearTokenCryptoCurrency( + "mpDAO", + "Meta Pool DAO", + "NEAR.mpDAO-mpdao-token.near", + "NEAR.mpDAO", + R.string.cryptocurrency_mpdao_code, + R.string.cryptocurrency_mpdao_near_network + ), + MayaNearTokenCryptoCurrency( + "nrUsdt", + "nrUsdt", + "NEAR.nrUsdt-lsd-usdt.rhealab.near", + "NEAR.nrUsdt", + R.string.cryptocurrency_nrusdt_code, + R.string.cryptocurrency_nrusdt_near_network + ), + MayaNearTokenCryptoCurrency( + "stNEAR", + "Staked NEAR", + "NEAR.stNEAR-meta-pool.near", + "NEAR.stNEAR", + R.string.cryptocurrency_stnear_code, + R.string.cryptocurrency_stnear_near_network + ), + MayaNearTokenCryptoCurrency( + "wBTC", + "wBTC", + "NEAR.wBTC-2260fac5e5542a773aa44fbcfedf7c193bc2c599.factory.bridge.near", + "NEAR.wBTC", + R.string.cryptocurrency_wbtc_code, + R.string.cryptocurrency_wbtc_near_network + ), + MayaNearTokenCryptoCurrency( + "wNEAR", + "Wrapped Near", + "NEAR.wNEAR-wrap.near", + "NEAR.wNEAR", + R.string.cryptocurrency_wnear_code, + R.string.cryptocurrency_wnear_near_network + ), + + // ----- TON chain tokens ----- + MayaTonTokenCryptoCurrency( + "USDT", + "USDT", + "TON.USDT-EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", + "TON.USDT", + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_ton_network + ), + + // ----- TRON chain tokens ----- + MayaTronTokenCryptoCurrency( + "USDT", + "USDT", + "TRON.USDT-TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "TRON.USDT", + R.string.cryptocurrency_tether_code, + R.string.cryptocurrency_tether_tron_network + ), + + // ----- SUI chain tokens ----- + MayaSuiTokenCryptoCurrency( + "USDC", + "USDC", + "SUI.USDC-0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "SUI.USDC", + R.string.cryptocurrency_usdcoin_code, + R.string.cryptocurrency_usdcoin_sui_network + ) ) currencyMap = currencyList.associateBy({ it.asset }, { it }) } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoAddressParser.kt new file mode 100644 index 0000000000..a82386c1d5 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoAddressParser.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** + * Cardano address parser — Shelley Bech32 addresses (HRP `addr`, length ~98) + * or legacy Byron Base58 (`Ae2tdPwUPEZ...` / `DdzFFzCqr...`). + */ +class CardanoAddressParser : AddressParser( + "(addr1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{53,98})|((Ae2|DdzFF)[1-9A-HJ-NP-Za-km-z]{50,110})", + null +) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoPaymentIntentParser.kt new file mode 100644 index 0000000000..014e786e06 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/CardanoPaymentIntentParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for Cardano (ADA) payment intents. Modern Shelley addresses are + * Bech32-encoded with HRP `addr` and ~58-103 chars. + */ +open class CardanoPaymentIntentParser( + currency: String = "ADA", + asset: String = "ADA.ADA", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "cardano", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(CardanoPaymentIntentParser::class.java) + private val addressParser = CardanoAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as ADA address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearAddressParser.kt new file mode 100644 index 0000000000..7c63b7d93e --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearAddressParser.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** + * NEAR account address parser: + * - implicit accounts: 64 lowercase hex characters + * - named accounts: 2-64 chars of `[a-z0-9_-]` separated by `.`, + * typically ending in `.near` + */ +class NearAddressParser : AddressParser( + "([a-f0-9]{64})|([a-z0-9_-]{2,64}(\\.[a-z0-9_-]{1,64})+)", + null +) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearPaymentIntentParser.kt new file mode 100644 index 0000000000..214c551027 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/NearPaymentIntentParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for NEAR Protocol payment intents. NEAR accounts can be either + * implicit (64 lowercase hex characters) or named (e.g. `alice.near`). + */ +open class NearPaymentIntentParser( + currency: String = "NEAR", + asset: String = "NEAR.NEAR", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "near", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(NearPaymentIntentParser::class.java) + private val addressParser = NearAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as NEAR address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SimpleBase58PaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SimpleBase58PaymentIntentParser.kt new file mode 100644 index 0000000000..fc4d81b9b1 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SimpleBase58PaymentIntentParser.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.AddressParser +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Generic Base58 / pattern-driven payment intent parser. Used for L1 chains + * (LTC, DOGE, BCH, etc.) where SwapKit performs the final address validation + * server-side and the wallet only needs lexical screening. + */ +open class SimpleBase58PaymentIntentParser( + currency: String, + uriPrefix: String, + asset: String, + private val pattern: String, + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, uriPrefix, asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(SimpleBase58PaymentIntentParser::class.java) + private val addressParser = AddressParser(pattern, null) + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as $currency address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaAddressParser.kt new file mode 100644 index 0000000000..d965d738dc --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaAddressParser.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** + * Solana address parser — Base58 ed25519 public key, 32 to 44 characters. + * Uses a permissive Base58 alphabet check; final validation is done by SwapKit. + */ +class SolanaAddressParser : AddressParser("[1-9A-HJ-NP-Za-km-z]{32,44}", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaPaymentIntentParser.kt new file mode 100644 index 0000000000..46437f1e6d --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SolanaPaymentIntentParser.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for Solana (SOL) payment intents. Solana addresses are Base58-encoded + * 32-byte ed25519 public keys (typically 32-44 characters). We only validate + * the lexical pattern; SwapKit verifies the address at quote time. + */ +open class SolanaPaymentIntentParser( + currency: String = "SOL", + asset: String = "SOL.SOL", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "solana", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(SolanaPaymentIntentParser::class.java) + private val addressParser = SolanaAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as SOL address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetAddressParser.kt new file mode 100644 index 0000000000..7e47c57f60 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetAddressParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** Starknet address parser — 0x followed by 1-64 hex characters (a 252-bit felt). */ +class StarknetAddressParser : AddressParser("0x[a-fA-F0-9]{1,64}", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetPaymentIntentParser.kt new file mode 100644 index 0000000000..36081653b8 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/StarknetPaymentIntentParser.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** Parser for Starknet (STRK) payment intents. Starknet addresses are 0x-prefixed felts up to 64 hex chars. */ +open class StarknetPaymentIntentParser( + currency: String = "STRK", + asset: String = "STRK.STRK", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "starknet", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(StarknetPaymentIntentParser::class.java) + private val addressParser = StarknetAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as STRK address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiAddressParser.kt new file mode 100644 index 0000000000..b16361693f --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiAddressParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** SUI address parser — 0x prefix followed by 64 hex characters (32 bytes). */ +class SuiAddressParser : AddressParser("0x[a-fA-F0-9]{64}", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiPaymentIntentParser.kt new file mode 100644 index 0000000000..7ea9a8e864 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/SuiPaymentIntentParser.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** Parser for SUI payment intents. SUI addresses are 0x-prefixed 32-byte hex strings. */ +open class SuiPaymentIntentParser( + currency: String = "SUI", + asset: String = "SUI.SUI", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "sui", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(SuiPaymentIntentParser::class.java) + private val addressParser = SuiAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as SUI address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonAddressParser.kt new file mode 100644 index 0000000000..072ad020f3 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonAddressParser.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** + * TON address parser — user-friendly Base64URL-encoded address, 48 chars. + * Also accepts the raw form `:<256-bit-hex>`. + */ +class TonAddressParser : AddressParser("([A-Za-z0-9_-]{48})|(-?\\d+:[a-fA-F0-9]{64})", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonPaymentIntentParser.kt new file mode 100644 index 0000000000..047f682759 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TonPaymentIntentParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for TON (Toncoin) payment intents. User-friendly TON addresses are + * Base64URL-encoded and 48 characters long. + */ +open class TonPaymentIntentParser( + currency: String = "TON", + asset: String = "TON.TON", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "ton", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(TonPaymentIntentParser::class.java) + private val addressParser = TonAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as TON address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronAddressParser.kt new file mode 100644 index 0000000000..b8a8af648a --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronAddressParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** TRON address parser — Base58Check, 34 characters, leading `T`. */ +class TronAddressParser : AddressParser("T[1-9A-HJ-NP-Za-km-z]{33}", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronPaymentIntentParser.kt new file mode 100644 index 0000000000..fc537e67c6 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/TronPaymentIntentParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for TRON (TRX) payment intents. Tron base58check addresses begin + * with `T` and are 34 characters long. + */ +open class TronPaymentIntentParser( + currency: String = "TRX", + asset: String = "TRON.TRX", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "tron", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(TronPaymentIntentParser::class.java) + private val addressParser = TronAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as TRON address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpAddressParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpAddressParser.kt new file mode 100644 index 0000000000..b182181b1d --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpAddressParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import org.dash.wallet.common.payments.parsers.AddressParser + +/** XRP classic address parser — Base58Check, leading `r`, 25-35 chars. */ +class XrpAddressParser : AddressParser("r[1-9A-HJ-NP-Za-km-z]{24,34}", null) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpPaymentIntentParser.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpPaymentIntentParser.kt new file mode 100644 index 0000000000..f06716928c --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/XrpPaymentIntentParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.integrations.maya.payments.parsers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinj.core.AddressFormatException +import org.dash.wallet.common.R +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.payments.parsers.PaymentIntentParserException +import org.dash.wallet.common.util.ResourceString +import org.slf4j.LoggerFactory + +/** + * Parser for XRP (Ripple) payment intents. Classic XRP addresses are + * Base58Check encoded with a leading `r` and are 25-35 characters long. + */ +open class XrpPaymentIntentParser( + currency: String = "XRP", + asset: String = "XRP.XRP", + shortAsset: String? = null +) : MayaPaymentIntentParser(currency, "xrp", asset, shortAsset, null) { + private val log = LoggerFactory.getLogger(XrpPaymentIntentParser::class.java) + private val addressParser = XrpAddressParser() + + override suspend fun parse(input: String): PaymentIntent = withContext(Dispatchers.Default) { + if (input.startsWith("$uriPrefix:") || input.startsWith("${uriPrefix.uppercase()}:")) { + try { + val address = input.substring(uriPrefix.length + 1) + return@withContext createPaymentIntent(address) + } catch (ex: Exception) { + log.info("got invalid uri: '$input'", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf(input)) + ) + } + } else if (addressParser.exactMatch(input)) { + try { + return@withContext createPaymentIntent(input) + } catch (ex: AddressFormatException) { + log.info("got invalid address", ex) + throw PaymentIntentParserException( + ex, + ResourceString(R.string.error, listOf()) + ) + } + } + log.info("cannot classify as XRP address: '{}'", input) + throw PaymentIntentParserException( + IllegalArgumentException(input), + ResourceString(R.string.error, listOf(input)) + ) + } +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt new file mode 100644 index 0000000000..8d19f01f46 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt @@ -0,0 +1,516 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit + +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.bitcoinj.core.Address +import org.bitcoinj.core.Coin +import org.bitcoinj.script.ScriptPattern +import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.WalletDataProvider +import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.util.toBigDecimal +import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.api.MayaBlockchainApi +import org.dash.wallet.integrations.maya.api.MayaException +import org.dash.wallet.integrations.maya.api.SwapProvider +import org.dash.wallet.integrations.maya.model.AccountDataUIModel +import org.dash.wallet.integrations.maya.model.Account +import org.dash.wallet.integrations.maya.model.Balance +import org.dash.wallet.integrations.maya.model.InboundAddress +import org.dash.wallet.integrations.maya.model.PoolInfo +import org.dash.wallet.integrations.maya.model.SwapFees +import org.dash.wallet.integrations.maya.model.SwapQuote +import org.dash.wallet.integrations.maya.model.SwapQuoteRequest +import org.dash.wallet.integrations.maya.model.SwapTradeUIModel +import org.dash.wallet.integrations.maya.utils.MayaConstants +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitFee +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitRoute +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapRequest +import org.dash.wallet.integrations.maya.ui.MayaViewModel +import org.slf4j.LoggerFactory +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** + * SwapKit-backed implementation of [SwapProvider]. + * + * Strategy: query SwapKit's MAYACHAIN aggregator for everything the wallet UI needs, + * mapping responses onto the existing Maya-shaped DTOs ([PoolInfo], [SwapQuote], + * [SwapTradeUIModel], [InboundAddress]) so ViewModels can switch backends without + * code changes. CACAO-specific fields on [PoolInfo] stay blank — only the fields + * the wallet actually reads (asset, currencyCode, assetPriceFiat, status) are + * populated, with [PoolInfo.assetPriceFiat] computed directly from `/price` rather + * than the stable-pool cross-product Maya uses. + * + * The DASH transaction itself is still built by [MayaBlockchainApi.buildAndSendSwapTx] + * — SwapKit's `/v3/swap` response yields the same `vaultAddress` + `memo` shape the + * existing builder needs, so no PSBT parsing is required for DASH-as-source. + */ +class SwapKitApiAggregator @Inject constructor( + private val webApi: SwapKitWebApi, + private val blockchainApi: MayaBlockchainApi, + private val walletDataProvider: WalletDataProvider +) : SwapProvider { + companion object { + private val log = LoggerFactory.getLogger(SwapKitApiAggregator::class.java) + private val UPDATE_FREQ_MS = TimeUnit.SECONDS.toMillis(30) + private val DASH_BASE_UNITS = BigDecimal("100000000") // 1e8 + } + + override val poolInfoList = MutableStateFlow>(emptyList()) + override val apiError = MutableStateFlow(null) + override var notificationIntent: Intent? = null + override var showNotificationOnResult: Boolean = false + + private val responseScope = CoroutineScope( + Executors.newSingleThreadExecutor().asCoroutineDispatcher() + ) + private var poolListLastUpdated: Long = 0L + + // Asset → USD price, captured at refresh time. applyPoolPrices re-seeds from + // this cache so it stays idempotent across re-emissions AND handles + // selected-currency switches without re-fetching from SwapKit. + private val usdPriceCache = mutableMapOf() + + override suspend fun reset() { + log.info("swapkit reset") + poolInfoList.value = emptyList() + apiError.value = null + poolListLastUpdated = 0L + usdPriceCache.clear() + } + + override fun observePoolList(fiatExchangeRate: Fiat): Flow> { + if (shouldRefresh()) { + responseScope.launch { + refreshPools(fiatExchangeRate) + poolListLastUpdated = System.currentTimeMillis() + } + } + return poolInfoList + } + + private fun shouldRefresh(): Boolean { + val now = System.currentTimeMillis() + return poolListLastUpdated == 0L || now - poolListLastUpdated > UPDATE_FREQ_MS + } + + private suspend fun refreshPools(fiatExchangeRate: Fiat) { + val reachable = webApi.getSwapTo(SwapKitConstants.DASH_ASSET) + if (reachable.isEmpty()) { + log.info("swapkit /swapTo returned no assets — leaving pool list as is") + return + } + // /swapTo returns reachable buy-assets — i.e. it never includes the source + // asset itself. The convert screen looks up DASH's own USD price via + // `getPoolInfo("DASH")` to compute the DASH↔fiat ratio, so we add DASH.DASH + // explicitly. The picker excludes DASH.DASH separately, so this only + // surfaces in price-lookup paths. + val identifiers = (reachable + SwapKitConstants.DASH_ASSET).distinct() + + val prices = webApi.getPrices(identifiers) + .associateBy({ it.identifier.uppercase() }, { it.priceUsd }) + + // Populate `assetPriceFiat` with the raw USD price (stored as a Fiat with code "USD"). + // [applyPoolPrices] then converts USD → selected fiat in a second pass — same + // contract Maya uses (raw price in pools, fiat conversion in the ViewModel pipeline). + // Also seed usdPriceCache so applyPoolPrices can re-seed on subsequent + // invocations (currency switch, repeat emissions) without re-fetching. + usdPriceCache.clear() + val pools = identifiers.map { identifier -> + val priceUsd = prices[identifier.uppercase()] ?: 0.0 + val priceUsdFiat = if (priceUsd > 0.0) { + val priceBd = BigDecimal(priceUsd) + usdPriceCache[identifier] = priceBd + priceBd.toFiat(MayaConstants.DEFAULT_EXCHANGE_CURRENCY) + } else { + Fiat.valueOf(MayaConstants.DEFAULT_EXCHANGE_CURRENCY, 0) + } + PoolInfo(asset = identifier, status = "Available").also { + it.assetPriceFiat = priceUsdFiat + } + } + poolInfoList.value = pools + } + + override suspend fun getInboundAddresses(): List { + // SwapKit returns the deposit address inline with /v3/swap, so we don't have + // a vault list. Synthesise one entry per chain that DASH can reach via SwapKit, + // with halted=false — enough for the picker filter and the "any halted?" toast. + // Prefer the cached pool list when populated; otherwise fall back to a direct + // `/swapTo` call so the first invocation isn't blocked behind the pool refresh. + val cached = poolInfoList.value + val identifiers = if (cached.isNotEmpty()) { + cached.map { it.asset } + } else { + webApi.getSwapTo(SwapKitConstants.DASH_ASSET) + } + val chains = identifiers.map { it.substringBefore('.') }.toSet() + return chains.map { InboundAddress(chain = it, halted = false) } + } + + override suspend fun getDefaultSwapQuote(toAsset: String, value: Long): SwapQuote? { + val sellAmount = baseUnitsToHumanDash(value) + val response = webApi.getQuote( + SwapKitQuoteRequest( + sellAsset = SwapKitConstants.DASH_ASSET, + buyAsset = toAsset, + sellAmount = sellAmount, + slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT + ) + ) ?: return null + return mapToSwapQuote(response.routes.bestRoute(), toAsset, response.error) + } + + override suspend fun getDefaultSwapQuote( + toAsset: String, + destinationAddress: String, + value: Long + ): SwapQuote? { + val sellAmount = baseUnitsToHumanDash(value) + val response = webApi.getQuote( + SwapKitQuoteRequest( + sellAsset = SwapKitConstants.DASH_ASSET, + buyAsset = toAsset, + sellAmount = sellAmount, + slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT, + destinationAddress = destinationAddress + ) + ) ?: return null + return mapToSwapQuote(response.routes.bestRoute(), toAsset, response.error) + } + + override suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource { + val sellAmount = swapRequest.amount.dash.setScale(8, RoundingMode.HALF_UP).toPlainString() + val sourceAddress = walletDataProvider.wallet?.currentReceiveAddress()?.toBase58() + ?: return ResponseResource.Failure(MayaException("wallet not loaded"), false, 0, null) + + val map = hashMapOf() + walletDataProvider.wallet!!.unspents.forEach { output -> + when { + ScriptPattern.isP2PKH(output.scriptPubKey) -> ScriptPattern.extractHashFromP2PKH(output.scriptPubKey) + else -> null + }?.let { + val address = Address.fromPubKeyHash(walletDataProvider.networkParameters, it) + map.computeIfPresent(address) { _, value -> output.value + value } + map.computeIfAbsent(address) { + output.value + } + } + } + val maxAddressBalance = map.values.maxOf { it } + val address = map.entries.find { maxAddressBalance == it.value }?.key + + val quote = webApi.getQuote( + SwapKitQuoteRequest( + sellAsset = swapRequest.source_maya_asset, + buyAsset = swapRequest.target_maya_asset, + sellAmount = sellAmount, + slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT, + // sourceAddress = sourceAddress, + destinationAddress = swapRequest.targetAddress + ) + ) ?: return ResponseResource.Failure(MayaException("swapkit quote failed"), false, 0, null) + + if (quote.error != null) { + return ResponseResource.Failure(MayaException(quote.error), false, 0, null) + } + val route = quote.routes.bestRoute() + ?: return ResponseResource.Failure( + MayaException(quote.providerErrors?.firstOrNull()?.message ?: "no swapkit route"), + false, + 0, + null + ) + + // Prefer the wallet's max-balance unspent address; fall back to the current + // receive address if the wallet has no P2PKH unspents (e.g. brand-new wallet). + // SwapKit still validates the address format even with disableBalanceCheck=true, + // so the value must be a real DASH address — but it must belong to *this* wallet + // so any refund SwapKit issues lands back here. + // + // disableBuildTx=true is required on UTXO chains: per SwapKit docs, + // disableBalanceCheck alone is ignored on UTXO chains because the tx-building + // step itself fetches per-address balance. We don't need SwapKit to build the + // DASH tx (MayaBlockchainApi.buildAndSendSwapTx does it locally from + // vaultAddress+memo), so skipping the build also skips the per-address check + // that would otherwise fail for HD wallets with balance spread across UTXOs. + val swap = webApi.postSwap( + SwapKitSwapRequest( + routeId = route.routeId, + sourceAddress = address?.toBase58() ?: sourceAddress, + destinationAddress = swapRequest.targetAddress, + disableBalanceCheck = true, + disableBuildTx = true + ) + ) ?: return ResponseResource.Failure(MayaException("swapkit /v3/swap failed"), false, 0, null) + + if (swap.error != null) { + val errorMessage = StringBuilder().apply { + append(swap.error) + if (swap.message != null) { + append(": ") + append(swap.message) + } + } + return ResponseResource.Failure(MayaException(errorMessage.toString()), false, 0, null) + } + + val vault = swap.targetAddress ?: swap.inboundAddress + ?: return ResponseResource.Failure(MayaException("swapkit returned no vault address"), false, 0, null) + val memo = swap.memo + //?: return ResponseResource.Failure(MayaException("swapkit returned no memo"), false, 0, null) + + val feeAmount = swapRequest.amount.copy().apply { + // Sum the SwapKit fee breakdown, converting each leg to DASH via the + // user's market rate. Captures inbound (DASH) + outbound (target) + + // anything denominated in either. Routing-asset fees (CACAO/RUNE for + // Maya/Thor) are skipped — we don't have a rate to convert them. + dash = totalSwapCostInDash(swapRequest, route, swap.fees) + anchoredType = swapRequest.amount.anchoredType + } + + // Pass swapRequest.amount through unchanged. Mirrors MayaWebApi (which also + // passes `amount = swapRequest.amount` to SwapTradeUIModel). DO NOT set + // .crypto from swap.expectedBuyAmount here — Amount.crypto's setter flips + // the anchor to Crypto and recomputes _dash from crypto/rate, which silently + // destroys the user's actual sell amount. The preview shows the pool-price + // crypto estimate; what actually arrives is the on-chain payout. + + val result = SwapTradeUIModel( + amount = swapRequest.amount, + outputAsset = swapRequest.target_maya_asset, + feeAmount = feeAmount, + vaultAddress = vault, + destinationAddress = swapRequest.targetAddress, + memo = memo, + maximum = swapRequest.maximum, + routeName = route.providers.joinToString(","), + availableRoutes = quote.routes.map { "${it.providers.joinToString(",")}: ${it.meta?.tags ?: listOf() }" } + ) + return ResponseResource.Success(result) + } + + override suspend fun commitSwapTransaction( + tradeId: String, + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource { + // Refresh the route via SwapKit (routeId expires after 60s) and then hand off + // to the existing DASH-tx builder. + val refreshed = getSwapInfo( + SwapQuoteRequest( + amount = swapTradeUIModel.amount, + source_maya_asset = SwapKitConstants.DASH_ASSET, + target_maya_asset = swapTradeUIModel.outputAsset, + fiatCurrency = swapTradeUIModel.amount.fiatCode, + targetAddress = swapTradeUIModel.destinationAddress, + maximum = swapTradeUIModel.maximum + ) + ) + return if (refreshed is ResponseResource.Success) { + blockchainApi.buildAndSendSwapTx(refreshed.value) + } else { + refreshed + } + } + + override suspend fun getUserAccounts(currency: String): List { + return listOf( + AccountDataUIModel( + Account(UUID.randomUUID(), currency, currency, currency, Balance("0", currency), true, true, "", true), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO + ) + ) + } + + private fun baseUnitsToHumanDash(value: Long): String { + return BigDecimal.valueOf(value) + .divide(DASH_BASE_UNITS, 8, RoundingMode.HALF_UP) + .toPlainString() + } + + private fun mapToSwapQuote(route: SwapKitRoute?, toAsset: String, topLevelError: String?): SwapQuote? { + if (route == null) { + return SwapQuote( + dustThreshold = "0", + expectedAmountOut = "0", + expiry = 0L, + fees = SwapFees("0", toAsset, "0", "0", 0, "0", 0), + inboundAddress = "", + inboundConfirmationBlocks = 0, + inboundConfirmationSeconds = 0.0, + memo = "", + notes = "", + outboundDelayBlocks = 0, + outboundDelaySeconds = 0.0, + recommendedMinAmountIn = "0", + slippageBps = 0, + warning = "", + error = topLevelError ?: "no route" + ) + } + val expectedBaseUnits = humanToBuyAssetBaseUnits(route.expectedBuyAmount) + val outboundBaseUnits = outboundFeeBaseUnits(route) + // Maya's SwapQuote/SwapFees expose slippage as Int; SwapKit returns it as + // a fractional Double. Round at the boundary. + val slippageBpsInt = route.totalSlippageBps.toInt() + return SwapQuote( + dustThreshold = "0", + expectedAmountOut = expectedBaseUnits, + expiry = (System.currentTimeMillis() / 1000) + 60, + fees = SwapFees( + affiliate = "0", + asset = toAsset, + liquidity = "0", + outbound = outboundBaseUnits, + slippageBps = slippageBpsInt, + total = outboundBaseUnits, + totalBps = slippageBpsInt + ), + inboundAddress = "", + inboundConfirmationBlocks = 0, + inboundConfirmationSeconds = route.estimatedTime?.inbound ?: 0.0, + memo = "", + notes = "", + outboundDelayBlocks = 0, + outboundDelaySeconds = route.estimatedTime?.outbound ?: 0.0, + recommendedMinAmountIn = "0", + slippageBps = slippageBpsInt, + warning = route.warnings?.joinToString().orEmpty(), + error = topLevelError + ) + } + + private fun humanToBuyAssetBaseUnits(human: String): String { + // Maya consumers downstream divide by 1e8 to get back to whole units; convert + // the SwapKit human decimal into matching base units. + return runCatching { + BigDecimal(human).multiply(DASH_BASE_UNITS).setScale(0, RoundingMode.HALF_UP).toPlainString() + }.getOrDefault("0") + } + + private fun outboundFeeBaseUnits(route: SwapKitRoute): String { + val outbound = route.fees.firstOrNull { it.type.equals("outbound", ignoreCase = true) } + ?: route.fees.firstOrNull { it.type.equals("network", ignoreCase = true) } + val human = outbound?.amount ?: return "0" + return runCatching { + BigDecimal(human).multiply(DASH_BASE_UNITS).setScale(0, RoundingMode.HALF_UP).toPlainString() + }.getOrDefault("0") + } + + private fun inboundFeeInDash(fees: List): BigDecimal { + val inbound = fees.firstOrNull { + it.type.equals("inbound", ignoreCase = true) && + (it.chain.equals("DASH", ignoreCase = true) || it.asset?.contains("DASH") == true) + } + val amt = inbound?.amount ?: return BigDecimal.ZERO + return runCatching { BigDecimal(amt) }.getOrDefault(BigDecimal.ZERO) + } + + private fun totalSwapCostInDash( + swapRequest: SwapQuoteRequest, + route: SwapKitRoute, + swapFees: List? + ): BigDecimal { + val fees = swapFees ?: route.fees + if (fees.isEmpty()) return BigDecimal.ZERO + + // cryptoDashExchangeRate is "target per DASH"; 1 target = 1/rate DASH. + val targetPerDash = swapRequest.amount.cryptoDashExchangeRate + // target_maya_asset is "CHAIN.SYMBOL", e.g. "THOR.RUNE" or "BTC.BTC". + val targetChain = swapRequest.target_maya_asset.substringBefore(".").uppercase() + val targetAsset = swapRequest.target_maya_asset.uppercase() + + // Sum fees from the SwapKit breakdown, converting non-DASH legs to DASH + // via the user's market rate. Using the input/output spread underreports + // for streaming routes because streaming nearly eliminates slippage — + // the network/liquidity/affiliate fees are still there, they just don't + // show up as a spread against market rate. + var total = BigDecimal.ZERO + fees.forEach { fee -> + val amt = runCatching { BigDecimal(fee.amount ?: "0") } + .getOrDefault(BigDecimal.ZERO) + if (amt.signum() <= 0) return@forEach + + val chain = fee.chain?.uppercase() + val asset = fee.asset?.uppercase() + val inDash = when { + chain == "DASH" || asset?.contains("DASH") == true -> amt + targetPerDash.signum() > 0 && + (chain == targetChain || asset == targetAsset) -> + amt.divide(targetPerDash, 16, RoundingMode.HALF_UP) + else -> { + // Routing-asset fees (CACAO for Maya, RUNE for Thor liquidity, + // etc.) need their own DASH rate to convert. Log and skip; + // undercounting is preferable to guessing. + log.info( + "swapkit fee skipped: type={} amount={} asset={} chain={}", + fee.type, fee.amount, fee.asset, fee.chain + ) + BigDecimal.ZERO + } + } + total = total.add(inDash) + } + return total + } + + private fun List.bestRoute(): SwapKitRoute? { + if (isEmpty()) return null + return firstOrNull { it.meta?.tags?.contains("RECOMMENDED") == true } + ?: firstOrNull { it.meta?.tags?.contains("CHEAPEST") == true } + ?: first() + } + + override fun applyPoolPrices(pools: List, usdToFiat: Fiat) { + // usdToFiat is the wallet's "1 USD in SELECTED_CURRENCY" rate. Unlike Maya + // (which recomputes USD from balance_cacao/balance_asset each pass), the + // SwapKit aggregator caches USD prices in usdPriceCache at refresh time + // and re-seeds from there on every call. This keeps the function + // idempotent across re-emissions (the original bug: 45.53 USD → 124.97 + // BYN → 346.06 → 949.92 → ... compounding every cycle) AND lets a + // selected-currency switch convert from the cached USD baseline without + // a network refetch. + val fiatPerUsd = usdToFiat.toBigDecimal() + + pools.forEach { pool -> + val priceUsd = usdPriceCache[pool.asset] + if (priceUsd == null || priceUsd.signum() <= 0) { + log.info("no USD price for {}", pool.asset) + return@forEach + } + pool.assetPriceFiat = priceUsd.multiply(fiatPerUsd).toFiat(usdToFiat.currencyCode) + log.info("$priceUsd, ${pool.assetPriceFiat} -> ${pool.asset}") + } + } +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt new file mode 100644 index 0000000000..20b034796f --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Adds the `x-api-key` header to every SwapKit request. If the configured key is + * blank the header is omitted — the API will reject the call with 401, which the + * web layer maps to a regular failure. This keeps non-credentialed builds usable + * (they will just not return successful SwapKit data). + */ +class SwapKitAuthInterceptor(private val apiKey: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + if (apiKey.isBlank()) return chain.proceed(original) + + val authed = original.newBuilder() + .header("x-api-key", apiKey) + .header("Accept", "application/json") + .build() + return chain.proceed(authed) + } +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt new file mode 100644 index 0000000000..2755e9abff --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit + +import org.dash.wallet.integrations.maya.BuildConfig + +object SwapKitConstants { + const val BASE_URL = "https://api.swapkit.dev/" + + const val DASH_ASSET = "DASH.DASH" + + /** Default slippage (percent) for indicative quotes. */ + const val DEFAULT_SLIPPAGE_PERCENT = 3 + + /** + * SwapKit API key, sourced from `service.properties` (SWAPKIT_API_KEY) at build + * time via Maya's BuildConfig. Blank when the property is absent — the Hilt + * switch falls back to Maya in that case, so the app remains functional without + * a key. + */ + const val API_KEY: String = BuildConfig.SWAPKIT_API_KEY +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt new file mode 100644 index 0000000000..24cb10cb38 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit + +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitPriceItem +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitPriceRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteResponse +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapResponse +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitTokenListResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * Retrofit surface for the SwapKit aggregator API. + * + * Reference: https://docs.swapkit.dev/swapkit-api/introduction + * + * All requests require an `x-api-key` header — the value is injected globally by + * [SwapKitAuthInterceptor] in [org.dash.wallet.integrations.maya.di.MayaModule]. + */ +interface SwapKitEndpoint { + @GET("tokens") + suspend fun getTokens(@Query("provider") provider: String): Response + + /** Returns identifiers reachable as buy-assets when selling [sellAsset]. */ + @GET("swapTo") + suspend fun getSwapTo(@Query("sellAsset") sellAsset: String): Response> + + @POST("v3/quote") + suspend fun postQuote(@Body request: SwapKitQuoteRequest): Response + + @POST("v3/swap") + suspend fun postSwap(@Body request: SwapKitSwapRequest): Response + + @POST("price") + suspend fun postPrice(@Body request: SwapKitPriceRequest): Response> +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt new file mode 100644 index 0000000000..5aa58e031d --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import org.dash.wallet.common.services.analytics.AnalyticsService +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitPriceItem +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitPriceRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitPriceTokenRef +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteResponse +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapResponse +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitToken +import org.slf4j.LoggerFactory +import java.io.IOException +import javax.inject.Inject + +/** + * Thin wrapper around [SwapKitEndpoint] with the same error-handling shape as + * [org.dash.wallet.integrations.maya.api.MayaWebApi] — exceptions and non-2xx + * responses degrade to safe defaults (empty list / null) and are logged. + */ +open class SwapKitWebApi @Inject constructor( + private val endpoint: SwapKitEndpoint, + private val analyticsService: AnalyticsService +) { + companion object { + private val log = LoggerFactory.getLogger(SwapKitWebApi::class.java) + } + + suspend fun getTokens(provider: String): List { + return safeCall("getTokens($provider)", emptyList()) { + val response = endpoint.getTokens(provider) + if (response.isSuccessful) response.body()?.tokens.orEmpty() else emptyList() + } + } + + suspend fun getSwapTo(sellAsset: String): List { + return safeCall("getSwapTo($sellAsset)", emptyList()) { + val response = endpoint.getSwapTo(sellAsset) + if (response.isSuccessful) response.body().orEmpty() else emptyList() + } + } + + suspend fun getQuote(request: SwapKitQuoteRequest): SwapKitQuoteResponse? { + return safeCall("getQuote(${request.sellAsset}->${request.buyAsset})", null) { + val response = endpoint.postQuote(request) + if (response.isSuccessful) { + response.body() + } else { + // SwapKit returns errors like {"error":"noRoutesFound","message":"...","data":{...}} + // as non-2xx responses, so the body is on errorBody(). Parse it so callers + // can react to the error code rather than seeing an opaque null. + parseErrorBody(response.errorBody()?.string()) + } + } + } + + private fun parseErrorBody(body: String?): SwapKitQuoteResponse? { + if (body.isNullOrBlank()) return null + return try { + Gson().fromJson(body, SwapKitQuoteResponse::class.java) + } catch (ex: JsonSyntaxException) { + log.warn("swapkit getQuote: could not parse error body: $ex") + null + } + } + + suspend fun postSwap(request: SwapKitSwapRequest): SwapKitSwapResponse? { + return safeCall("postSwap(${request.routeId})", null) { + val response = endpoint.postSwap(request) + if (response.isSuccessful) { + response.body() + } else { + // SwapKit /v3/swap returns errors like {"message":"Cannot build transaction..."} + // (and sometimes {"error":"...","message":"..."}) on non-2xx. Parse so the + // aggregator surfaces the real message instead of a generic failure string. + parseSwapErrorBody(response.errorBody()?.string()) + } + } + } + + private fun parseSwapErrorBody(body: String?): SwapKitSwapResponse? { + if (body.isNullOrBlank()) return null + return try { + val parsed = Gson().fromJson(body, SwapKitSwapResponse::class.java) ?: return null + // Aggregator checks `swap.error`; fall the message into that slot when the + // server only sent `message` so the existing error path picks it up. + if (parsed.error == null && !parsed.message.isNullOrBlank()) { + parsed.copy(error = parsed.message) + } else { + parsed + } + } catch (ex: JsonSyntaxException) { + log.warn("swapkit postSwap: could not parse error body: $ex") + null + } + } + + suspend fun getPrices(identifiers: List): List { + if (identifiers.isEmpty()) return emptyList() + return safeCall("getPrices(${identifiers.size})", emptyList()) { + val response = endpoint.postPrice( + SwapKitPriceRequest( + tokens = identifiers.map { SwapKitPriceTokenRef(it) }, + metadata = false + ) + ) + if (response.isSuccessful) response.body().orEmpty() else emptyList() + } + } + + private inline fun safeCall(label: String, fallback: T, block: () -> T): T { + return try { + block() + } catch (ex: Exception) { + log.error("swapkit $label: $ex") + if (ex !is IOException) { + analyticsService.logError(ex) + } + fallback + } + } +} \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt new file mode 100644 index 0000000000..4f5b509c26 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.swapkit.model + +import com.google.gson.annotations.SerializedName + +// /tokens?provider=NAME + +data class SwapKitTokenListResponse( + val provider: String? = null, + val name: String? = null, + val timestamp: String? = null, + val count: Int = 0, + val tokens: List = emptyList() +) + +data class SwapKitToken( + val chain: String? = null, + val address: String? = null, + val chainId: String? = null, + val ticker: String? = null, + val identifier: String = "", + val symbol: String? = null, + val name: String? = null, + val decimals: Int = 0, + val logoURI: String? = null, + val coingeckoId: String? = null +) + +// POST /v3/quote + +data class SwapKitQuoteRequest( + val sellAsset: String, + val buyAsset: String, + val sellAmount: String, + val slippage: Int? = null, + val sourceAddress: String? = null, + val destinationAddress: String? = null, + val providers: List? = null +) + +data class SwapKitQuoteResponse( + val quoteId: String? = null, + val routes: List = emptyList(), + val providerErrors: List? = null, + val error: String? = null, + val message: String? = null +) + +data class SwapKitRoute( + val routeId: String = "", + val providers: List = emptyList(), + val sellAsset: String? = null, + val buyAsset: String? = null, + val sellAmount: String? = null, + val expectedBuyAmount: String = "0", + val expectedBuyAmountMaxSlippage: String? = null, + val fees: List = emptyList(), + val estimatedTime: SwapKitEstimatedTime? = null, + val totalSlippageBps: Double = 0.0, + val warnings: List? = null, + val meta: SwapKitRouteMeta? = null +) + +data class SwapKitFee( + val type: String? = null, + val amount: String? = null, + val asset: String? = null, + val chain: String? = null +) + +data class SwapKitEstimatedTime( + val inbound: Double? = null, + val swap: Double? = null, + val outbound: Double? = null, + val total: Double? = null +) + +data class SwapKitRouteMeta( + val tags: List? = null, + val assets: List? = null +) + +data class SwapKitMetaAsset( + val asset: String? = null, + val price: Double? = null, + val image: String? = null +) + +data class SwapKitProviderError( + val provider: String? = null, + val errorCode: String? = null, + val message: String? = null +) + +// POST /v3/swap + +data class SwapKitSwapRequest( + val routeId: String, + val sourceAddress: String, + val destinationAddress: String, + val disableBalanceCheck: Boolean? = null, + val disableBuildTx: Boolean? = null, + val overrideSlippage: Boolean? = null +) + +data class SwapKitSwapResponse( + val swapId: String? = null, + val providers: List? = null, + val sellAsset: String? = null, + val buyAsset: String? = null, + val sellAmount: String? = null, + val expectedBuyAmount: String? = null, + val expectedBuyAmountMaxSlippage: String? = null, + val targetAddress: String? = null, + val inboundAddress: String? = null, + val memo: String? = null, + val fees: List? = null, + val txType: String? = null, + val error: String? = null, + val message: String? = null +) + +// POST /price + +data class SwapKitPriceRequest( + val tokens: List, + val metadata: Boolean = false +) + +data class SwapKitPriceTokenRef( + val identifier: String +) + +data class SwapKitPriceItem( + val identifier: String = "", + val provider: String? = null, + @SerializedName("price_usd") val priceUsd: Double = 0.0, + val timestamp: Long? = null +) \ No newline at end of file diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt index c8c5a18b86..bdae03359f 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.launch import org.dash.wallet.common.integrations.ExchangeIntegration import org.dash.wallet.common.integrations.ExchangeIntegrationProvider import org.dash.wallet.common.ui.address_input.AddressSource -import org.dash.wallet.integrations.maya.api.MayaWebApi +import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.SwapQuote import javax.inject.Inject @@ -19,7 +19,7 @@ import javax.inject.Inject @HiltViewModel class MayaAddressInputViewModel @Inject constructor( private val exchangeIntegrationProvider: ExchangeIntegrationProvider, - private val mayaWebApi: MayaWebApi + private val swapProvider: SwapProvider ) : ViewModel() { lateinit var asset: String private val inputCurrency = MutableStateFlow(null) @@ -53,10 +53,10 @@ class MayaAddressInputViewModel @Inject constructor( } suspend fun getDefaultQuote(): SwapQuote? { - return mayaWebApi.getDefaultSwapQuote(asset) + return swapProvider.getDefaultSwapQuote(asset) } suspend fun getDefaultQuote(destinationAddress: String): SwapQuote? { - return mayaWebApi.getDefaultSwapQuote(asset, destinationAddress) + return swapProvider.getDefaultSwapQuote(asset, destinationAddress) } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt index c1e62a5eb4..89f75ce0d5 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt @@ -16,6 +16,7 @@ */ package org.dash.wallet.integrations.maya.ui +import android.annotation.SuppressLint import android.os.Build import android.os.Bundle import android.os.CountDownTimer @@ -211,6 +212,7 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion binding.previewOfflineGroup.isVisible = hasInternet } + @SuppressLint("SetTextI18n") private fun SwapTradeUIModel.updateConversionPreviewUI() { newSwapOrderId = this.swapTradeId @@ -318,6 +320,14 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion placeholder(org.dash.wallet.common.R.drawable.ic_default_flag) transformations(CircleCropTransformation()) } + + val routeName = this.routeName + val routes = this.availableRoutes + binding.contentOrderReview.orderInfo.text = """ + selected: $routeName + + all: $routes + """.trimIndent() } private fun setValueWithCurrencyCodeOrSymbol( diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt index 938422c5dc..6b62a22486 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt @@ -36,8 +36,7 @@ import org.dash.wallet.common.services.TransactionMetadataProvider import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.common.transactions.filters.LockedTransaction -import org.dash.wallet.integrations.maya.api.MayaBlockchainApi -import org.dash.wallet.integrations.maya.api.MayaWebApi +import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.MayaErrorResponse import org.dash.wallet.integrations.maya.model.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeResponse @@ -49,8 +48,7 @@ import javax.inject.Inject @HiltViewModel class MayaConversionPreviewViewModel @Inject constructor( - private val mayaWebApi: MayaWebApi, - private val mayaBlockchainApi: MayaBlockchainApi, + private val swapProvider: SwapProvider, private val walletDataProvider: WalletDataProvider, private val analyticsService: AnalyticsService, networkState: NetworkStateInt, @@ -89,7 +87,7 @@ class MayaConversionPreviewViewModel @Inject constructor( // TODO: this is the action to do the swap _showLoading.value = true - when (val result = mayaBlockchainApi.commitSwapTransaction(tradeId, swapTradeUIModel)) { + when (val result = swapProvider.commitSwapTransaction(tradeId, swapTradeUIModel)) { is ResponseResource.Success -> { // Wait for the swap transaction to be IS-locked or confirmed on the network. // This verifies that the transaction was successfully broadcast and seen by peers. @@ -157,7 +155,7 @@ class MayaConversionPreviewViewModel @Inject constructor( targetAddress = swapTradeUIModel.destinationAddress, maximum = swapTradeUIModel.maximum ) - when (val result = mayaWebApi.getSwapInfo(tradesRequest)) { + when (val result = swapProvider.getSwapInfo(tradesRequest)) { is ResponseResource.Success -> { _showLoading.value = false if (result.value == SwapTradeResponse.EMPTY_SWAP_TRADE) { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 88a3a3676f..0b754edd24 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -50,8 +50,10 @@ import org.dash.wallet.integrations.maya.databinding.FragmentMayaConvertCryptoBi import org.dash.wallet.integrations.maya.model.Account import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.Balance +import org.dash.wallet.integrations.maya.model.MayaErrorType import org.dash.wallet.integrations.maya.model.getCoinBaseExchangeRateConversion import org.dash.wallet.integrations.maya.model.getMayaErrorString +import org.dash.wallet.integrations.maya.model.getMayaErrorType import org.dash.wallet.integrations.maya.ui.convert_currency.ConvertViewFragment import org.dash.wallet.integrations.maya.ui.convert_currency.ConvertViewViewModel import org.dash.wallet.integrations.maya.ui.convert_currency.model.ServiceWallet @@ -133,7 +135,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto lifecycleScope.launch { val dashInbound = try { mayaViewModel.refreshInboundAddresses() - mayaViewModel.inboundAddresses.value.find { it.chain == "DASH" } + mayaViewModel.isTradingActive() } catch (e: Exception) { AdaptiveDialog.create( R.drawable.ic_error, @@ -144,7 +146,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto return@launch } - if (dashInbound == null || dashInbound.halted) { + if (!dashInbound) { AdaptiveDialog.create( R.drawable.ic_error, getString(R.string.error), @@ -157,7 +159,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto val paymentIntent = try { viewModel.getUpdatedPaymentIntent( convertViewModel.enteredConvertDashAmount.value!!, - Address.fromBase58(null, dashInbound.address) + Address.fromBase58(null, swapTrade.vaultAddress) ) } catch (e: Exception) { AdaptiveDialog.create( @@ -181,6 +183,14 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto } viewModel.swapTradeFailedCallback.observe(viewLifecycleOwner) { + // SwapKit's `noRoutesFound` (and Maya's "amount too low") shouldn't pop a modal — + // surface them in the same red banner the local min-amount check uses, so the + // user can simply raise the amount and retry without dismissing a dialog. + if (!it.isNullOrBlank() && getMayaErrorType(it) == MayaErrorType.AMOUNT_TOO_LOW) { + showAmountTooLowBanner() + return@observe + } + val message: String = if (it.isNullOrBlank()) { requireContext().getString(R.string.something_wrong_title) } else { @@ -287,7 +297,6 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto } val swapValueErrorType = convertViewModel.checkEnteredAmountValue(checkSendingConditions) - lifecycleScope.launch { if (swapValueErrorType == SwapValueErrorType.NOError) { if (!request.dashToCrypto && convertViewModel.dashToCrypto.value == true) { @@ -385,6 +394,13 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto binding.limitDesc.text = getString(R.string.exchange_rate_not_found) } + private fun showAmountTooLowBanner() { + binding.authLimitBanner.root.isGone = true + binding.limitDesc.isVisible = true + binding.limitDesc.setText(R.string.maya_error_below_allowed_minimum) + setGuidelinePercent(false) + } + private fun setConvertViewInput() { convertViewModel.selectedCryptoCurrencyAccount.value?.let { it -> val accountData = it.coinbaseAccount diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt index fbb69581fc..936f33da7b 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt @@ -38,7 +38,7 @@ import org.dash.wallet.common.services.NetworkStateInt import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.common.util.Constants -import org.dash.wallet.integrations.maya.api.MayaWebApi +import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.MayaErrorResponse import org.dash.wallet.integrations.maya.model.SwapQuoteRequest @@ -50,7 +50,7 @@ import javax.inject.Inject @HiltViewModel class MayaConvertCryptoViewModel @Inject constructor( - private val coinBaseRepository: MayaWebApi, + private val swapProvider: SwapProvider, private val config: MayaConfig, private val walletUIConfig: WalletUIConfig, private val walletDataProvider: WalletDataProvider, @@ -94,10 +94,10 @@ class MayaConvertCryptoViewModel @Inject constructor( target_maya_asset = swapTradeInfo.cryptoCurrencyAsset, fiatCurrency = swapTradeInfo.fiatCurrencyCode, targetAddress = swapTradeInfo.destinationAddress, - maximum = swapTradeInfo.maximum + maximum = swapTradeInfo.maximum, ) - when (val result = coinBaseRepository.getSwapInfo(swapRequest)) { + when (val result = swapProvider.getSwapInfo(swapRequest)) { is ResponseResource.Success -> { if (result.value == SwapTradeResponse.EMPTY_SWAP_TRADE) { _showLoading.value = false @@ -142,7 +142,7 @@ class MayaConvertCryptoViewModel @Inject constructor( analyticsService.logEvent(AnalyticsConstants.Coinbase.CONVERT_SELECT_COIN, mapOf()) return try { - coinBaseRepository.getUserAccounts(walletUIConfig.getExchangeCurrencyCode()) + swapProvider.getUserAccounts(walletUIConfig.getExchangeCurrencyCode()) } catch (ex: Exception) { listOf() }.filter { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertResultViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertResultViewModel.kt index d57c65b387..37fe4b9516 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertResultViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertResultViewModel.kt @@ -23,13 +23,11 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import org.dash.wallet.common.data.SingleLiveEvent import org.dash.wallet.common.services.analytics.AnalyticsService -import org.dash.wallet.integrations.maya.api.MayaWebApi import org.dash.wallet.integrations.maya.model.MayaResultType import javax.inject.Inject @HiltViewModel class MayaConvertResultViewModel @Inject constructor( - private val mayaWebApi: MayaWebApi, private val analyticsService: AnalyticsService ) : ViewModel() { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalFragment.kt index 6551b8e362..5f70f24a80 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalFragment.kt @@ -30,6 +30,8 @@ import org.dash.wallet.common.util.safeNavigate @AndroidEntryPoint class MayaPortalFragment : Fragment() { + private val mayaViewModel by mayaViewModels() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -38,6 +40,7 @@ class MayaPortalFragment : Fragment() { return ComposeView(requireContext()).apply { setContent { MayaPortalScreen( + activeBackend = mayaViewModel.activeSwapBackend, onBackClick = { findNavController().popBackStack() }, diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalScreen.kt index e1062d7711..2bec18b386 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaPortalScreen.kt @@ -45,15 +45,32 @@ import org.dash.wallet.common.ui.components.MyTheme import org.dash.wallet.common.ui.components.TopIntro import org.dash.wallet.common.ui.components.TopNavBase import org.dash.wallet.integrations.maya.R +import org.dash.wallet.integrations.maya.utils.SwapBackend import org.dash.wallet.common.R as CommonR private val MayaLogoBackground = Color(0xFF151D3F) +private val SwapKitLogoBackground = Color(0xFFF5F5F5) @Composable fun MayaPortalScreen( + activeBackend: SwapBackend = SwapBackend.MAYA, onBackClick: () -> Unit = {}, onConvertClick: () -> Unit = {} ) { + val providerName = stringResource( + when (activeBackend) { + SwapBackend.MAYA -> R.string.maya_service_name + SwapBackend.SWAPKIT -> R.string.swapkit_service_name + } + ) + val providerLogoRes = when (activeBackend) { + SwapBackend.MAYA -> R.drawable.ic_maya_logo + SwapBackend.SWAPKIT -> R.drawable.ic_swapkit_logo + } + val providerLogoBackground = when (activeBackend) { + SwapBackend.MAYA -> MayaLogoBackground + SwapBackend.SWAPKIT -> SwapKitLogoBackground + } Column( modifier = Modifier .fillMaxSize() @@ -75,18 +92,18 @@ fun MayaPortalScreen( verticalArrangement = Arrangement.spacedBy(20.dp) ) { TopIntro( - heading = stringResource(R.string.maya_service_name), + heading = providerName, text = stringResource(R.string.maya_portal_subtitle) ) { Box( modifier = Modifier .size(width = 79.dp, height = 80.dp) - .background(MayaLogoBackground, RoundedCornerShape(20.dp)), + .background(providerLogoBackground, RoundedCornerShape(20.dp)), contentAlignment = Alignment.Center ) { Image( - painter = painterResource(R.drawable.ic_maya_logo), - contentDescription = null, + painter = painterResource(providerLogoRes), + contentDescription = providerName, modifier = Modifier.size(44.dp) ) } @@ -107,6 +124,12 @@ fun MayaPortalScreen( @Composable @Preview -private fun MayaPortalScreenPreview() { - MayaPortalScreen() +private fun MayaPortalScreenMayaPreview() { + MayaPortalScreen(activeBackend = SwapBackend.MAYA) +} + +@Composable +@Preview +private fun MayaPortalScreenSwapKitPreview() { + MayaPortalScreen(activeBackend = SwapBackend.SWAPKIT) } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt index be8e8b58f2..6c82cd40ae 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bitcoinj.core.Coin import org.bitcoinj.utils.Fiat import org.bitcoinj.utils.MonetaryFormat import org.dash.wallet.common.Configuration @@ -31,16 +33,23 @@ import org.dash.wallet.common.data.WalletUIConfig import org.dash.wallet.common.data.entity.ExchangeRate import org.dash.wallet.common.services.ExchangeRatesProvider import org.dash.wallet.common.services.analytics.AnalyticsService +import org.dash.wallet.common.util.Constants import org.dash.wallet.common.util.GenericUtils import org.dash.wallet.common.util.isCurrencyFirst import org.dash.wallet.common.util.toBigDecimal import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.api.DispatchingSwapProvider import org.dash.wallet.integrations.maya.api.FiatExchangeRateProvider import org.dash.wallet.integrations.maya.api.MayaApi +import org.dash.wallet.integrations.maya.api.MayaApiAggregator +import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.InboundAddress import org.dash.wallet.integrations.maya.model.PoolInfo import org.dash.wallet.integrations.maya.payments.MayaCurrencyList +import org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator +import org.dash.wallet.integrations.maya.swapkit.SwapKitConstants import org.dash.wallet.integrations.maya.utils.MayaConfig +import org.dash.wallet.integrations.maya.utils.SwapBackend import org.slf4j.Logger import org.slf4j.LoggerFactory import java.math.BigDecimal @@ -57,7 +66,7 @@ data class MayaPortalUIState( class MayaViewModel @Inject constructor( private val globalConfig: Configuration, private val config: MayaConfig, - private val mayaApi: MayaApi, + private val swapProvider: SwapProvider, private val fiatExchangeRateProvider: FiatExchangeRateProvider, exchangeRatesProvider: ExchangeRatesProvider, val analytics: AnalyticsService, @@ -74,7 +83,7 @@ class MayaViewModel @Inject constructor( val networkError = SingleLiveEvent() - // private var dashExchangeRate: org.bitcoinj.utils.ExchangeRate? = null + //private var dashExchangeRate: org.bitcoinj.utils.ExchangeRate? = null private var fiatExchangeRate: Fiat? = null private val _uiState = MutableStateFlow(MayaPortalUIState()) @@ -83,6 +92,14 @@ class MayaViewModel @Inject constructor( val dashFormat: MonetaryFormat get() = globalConfig.format.noCode() + /** + * The currently-active swap backend. Resolves through [DispatchingSwapProvider] so + * the portal screen can show the correct provider name + logo. Falls back to MAYA + * if the swap provider isn't the dispatcher (defensive — shouldn't happen in prod). + */ + val activeSwapBackend: SwapBackend + get() = (swapProvider as? DispatchingSwapProvider)?.currentBackend() ?: SwapBackend.MAYA + val poolList = MutableStateFlow>(listOf()) private val _inboundAddresses = MutableStateFlow>(emptyList()) val inboundAddresses: StateFlow> = _inboundAddresses.asStateFlow() @@ -100,6 +117,20 @@ class MayaViewModel @Inject constructor( _exchangeRates.value = it }.launchIn(viewModelScope) + walletUIConfig.observe(WalletUIConfig.SELECTED_CURRENCY) + .filterNotNull() + .flatMapLatest(exchangeRatesProvider::observeExchangeRate) + .filterNotNull() + .onEach { exchangeRate -> + val usdPrice = exchangeRatesProvider.getExchangeRate(Constants.USD_CURRENCY) + if (usdPrice != null) { + val rate = exchangeRate.rate!!.toDouble() / usdPrice.rate!!.toDouble() + log.info("exchange rate from CTX: {}", rate) + } + } + .launchIn(viewModelScope) + + walletUIConfig.observe(WalletUIConfig.SELECTED_CURRENCY) .filterNotNull() .onEach { log.info("exchange rate selected currency: {}", it) } @@ -111,12 +142,12 @@ class MayaViewModel @Inject constructor( log.info("exchange rate: {}", fiatRate) } .flatMapLatest { fiatRate -> - mayaApi.observePoolList(fiatRate.fiat).mapLatest { pools -> + swapProvider.observePoolList(fiatRate.fiat).mapLatest { pools -> pools to fiatRate.fiat } } .onEach { (newPoolList, usdToFiat) -> - applyPoolPrices(newPoolList, usdToFiat) + swapProvider.applyPoolPrices(newPoolList, usdToFiat) log.info( "exchange rate Pool List: {}", newPoolList.map { pool -> "${pool.asset}=${pool.assetPriceFiat.toFriendlyString()}" } @@ -125,6 +156,16 @@ class MayaViewModel @Inject constructor( } .launchIn(viewModelScope) + // Re-fetch inbound addresses whenever the pool list transitions to non-empty. + // SwapKit's getInboundAddresses() can return an empty set on the very first + // call if the pool refresh is still in flight; this catches up once the + // pools land. Maya is unaffected (its addresses come from a separate + // endpoint and don't depend on pool state). + poolList + .filter { it.isNotEmpty() && _inboundAddresses.value.isEmpty() } + .onEach { refreshInboundAddresses() } + .launchIn(viewModelScope) + updateInboundAddresses() } @@ -210,7 +251,7 @@ class MayaViewModel @Inject constructor( } suspend fun refreshInboundAddresses() { - _inboundAddresses.value = mayaApi.getInboundAddresses() + _inboundAddresses.value = withContext(Dispatchers.IO) { swapProvider.getInboundAddresses() } } fun getInboundAddress(asset: String): InboundAddress? { @@ -219,4 +260,40 @@ class MayaViewModel @Inject constructor( inboundAddresses.value.find { it.chain == chain } } else { null } } + + fun isTradingActive(): Boolean { + return when (swapProvider) { + is MayaApiAggregator -> { + val dashInbound = _inboundAddresses.value.find { it.chain == "DASH" } + if (dashInbound == null) { + false + } else { + dashInbound.halted != true + } + } + + is SwapKitConstants -> { + inboundAddresses.value.isNotEmpty() + } + is DispatchingSwapProvider -> { + when (swapProvider.active) { + is MayaApiAggregator -> { + val dashInbound = _inboundAddresses.value.find { it.chain == "DASH" } + if (dashInbound == null) { + false + } else { + dashInbound.halted != true + } + } + + is SwapKitApiAggregator -> { + inboundAddresses.value.isNotEmpty() + } + + else -> false + } + } + else -> false + } + } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt index d4329033ba..83c845fcbd 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt @@ -41,7 +41,7 @@ import org.dash.wallet.common.util.Constants import org.dash.wallet.common.util.GenericUtils import org.dash.wallet.common.util.toBigDecimal import org.dash.wallet.common.util.toCoin -import org.dash.wallet.integrations.maya.api.MayaWebApi +import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.Amount import org.dash.wallet.integrations.maya.model.CurrencyInputType @@ -63,7 +63,7 @@ class ConvertViewViewModel @Inject constructor( private val walletUIConfig: WalletUIConfig, private val walletDataProvider: WalletDataProvider, private val analyticsService: AnalyticsService, - private val mayaWebApi: MayaWebApi, + private val swapProvider: SwapProvider, private val savedStateHandle: SavedStateHandle ) : ViewModel() { companion object { @@ -166,7 +166,7 @@ class ConvertViewViewModel @Inject constructor( fun setSelectedAsset(asset: String) { viewModelScope.launch { - val quote = mayaWebApi.getDefaultSwapQuote(asset) + val quote = swapProvider.getDefaultSwapQuote(asset) val minAmount = amount.copy() if (quote != null && quote.error == null) { minAmount.dash = quote.recommendedMinAmountIn.toBigDecimal() diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/dialogs/MayaResultDialog.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/dialogs/MayaResultDialog.kt index b45a0c8cbc..69d829b7b0 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/dialogs/MayaResultDialog.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/dialogs/MayaResultDialog.kt @@ -27,8 +27,11 @@ import android.view.ViewGroup import android.view.Window import android.widget.Toast import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import org.dash.wallet.common.ui.viewBinding import org.dash.wallet.common.util.Constants @@ -61,6 +64,17 @@ class MayaResultDialog : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // Android 15 (targetSdk 35) forces edge-to-edge, and dialog windows often skip + // the layout's fitsSystemWindows handling — apply system-bar insets as padding + // here so the bottom close button clears the nav bar on every device. + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + v.updatePadding(top = bars.top, bottom = bars.bottom) + insets + } + val type = arguments?.getInt("Type") val sourceCurrency = arguments?.getString(ARG_SOURCE) ?: Constants.DASH_CURRENCY val destinationCurrency = arguments?.getString(ARG_DESTINATION) ?: getString(R.string.error) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt index db3ee055f4..d978159319 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt @@ -40,5 +40,11 @@ open class MayaConfig @Inject constructor( val EXCHANGE_RATE_LAST_UPDATE = longPreferencesKey("exchange_rate_last_update") val EXCHANGE_RATE_VALUE = doublePreferencesKey("exchange_rate_value") val EXCHANGE_RATE_CURRENCY_CODE = stringPreferencesKey("exchange_rate_currency_code") + + /** + * Stores the [SwapBackend] name. Read once at app start by the Hilt + * `SwapProvider` provider; changes take effect on the next launch. + */ + val SWAP_BACKEND = stringPreferencesKey("swap_backend") } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt new file mode 100644 index 0000000000..ba6b2fd4b7 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.utils + +/** + * Which backend powers the cross-chain swap surface. Selected once at app startup + * via the Hilt provider in + * [org.dash.wallet.integrations.maya.di.MayaModule.provideSwapProvider]; changes + * take effect on the next launch. + */ +enum class SwapBackend { + MAYA, + SWAPKIT +} \ No newline at end of file diff --git a/integrations/maya/src/main/res/drawable/ic_swapkit_logo.xml b/integrations/maya/src/main/res/drawable/ic_swapkit_logo.xml new file mode 100644 index 0000000000..59ce5357a1 --- /dev/null +++ b/integrations/maya/src/main/res/drawable/ic_swapkit_logo.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml b/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml index d92a6d40af..f83b2b8197 100644 --- a/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml +++ b/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml @@ -258,5 +258,16 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/total_label" tools:text="$ 200.00" /> + + diff --git a/integrations/maya/src/main/res/values/strings-maya.xml b/integrations/maya/src/main/res/values/strings-maya.xml index 4f464b0e68..bf90a8fcb8 100644 --- a/integrations/maya/src/main/res/values/strings-maya.xml +++ b/integrations/maya/src/main/res/values/strings-maya.xml @@ -16,6 +16,7 @@ --> Maya + SwapKit Convert Dash Convert Dash to From Dash Wallet to any crypto @@ -79,6 +80,201 @@ Radix MAYA Maya (Maya) + CACAO + Maya Protocol (Maya) + + + + + ADA + Cardano + AVAX + Avalanche + BCH + Bitcoin Cash + BERA + Berachain + BNB + BNB (BSC) + DOGE + Dogecoin + LTC + Litecoin + MON + Monad + NEAR + NEAR Protocol + OKB + OKB (X Layer) + POL + POL (Polygon) + SOL + Solana + STRK + Starknet + SUI + Sui + TON + Toncoin + TRX + TRON + XDAI + xDAI (Gnosis) + XRP + XRP + + + Ethereum (Base) + Ethereum (Optimism) + Ethereum (NEAR) + + + ADI + ADI (Ethereum) + AURORA + Aurora (Ethereum) + Aurora (NEAR) + cbBTC + Coinbase Wrapped BTC (Ethereum) + Coinbase Wrapped BTC (Base) + Dai (Ethereum) + MOG + Mog Coin (Ethereum) + SAFE + Safe (Ethereum) + Safe (Gnosis) + SHIB + Shiba Inu (Ethereum) + TURBO + Turbo (Ethereum) + Turbo (Solana) + TURBO (NEAR) + USD1 + USD1 (Ethereum) + USDf + Falcon USD (Ethereum) + Wrapped Bitcoin (Ethereum) + wBTC (NEAR) + WETH + WETH (Ethereum) + WETH (Arbitrum) + WETH (Base) + WETH (Optimism) + WETH (Polygon) + WETH (Gnosis) + + + USDT0 + USDT0 (Arbitrum) + USDT0 (Berachain) + USDT0 (Monad) + USDT0 (X Layer) + + + CFI + ConsumerFi Protocol (Base) + CFI (NEAR) + USD Coin (Base) + + + OP + OP (Optimism) + USD Coin (Optimism) + Tether (Optimism) + + + USD Coin (Avalanche) + Tether (Avalanche) + + + ASTER + Aster (BSC) + NEAR (BSC) + RHEA + RHEA (BSC) + RHEA (NEAR) + USD Coin (BSC) + Tether (BSC) + + + USD Coin (Polygon) + Tether (Polygon) + + + USD Coin (Monad) + + + USD Coin (X Layer) + + + COW + CoW Protocol (Gnosis) + EURe + EURe (Gnosis) + GNO + Gnosis Token (Gnosis) + USD Coin (Gnosis) + Tether (Gnosis) + + + WIF + dogwifhat (Solana) + PENGU + Pudgy Penguins (Solana) + SPX + SPX6900 (Solana) + TRUMP + Official Trump (Solana) + USD Coin (Solana) + Tether (Solana) + Zcash (Solana) + xBTC + OKX Wrapped BTC (Solana) + + + Bitcoin (NEAR) + FRAX + FRAX (NEAR) + ITLX + Intellex (NEAR) + JAMBO + JAMBO (NEAR) + NOEAR + NOEAR (NEAR) + NPRO + NPRO (NEAR) + NearKat + NearKat (NEAR) + PUBLIC + PublicAI (NEAR) + PURGE + PURGE (NEAR) + SHITZU + Shitzu (NEAR) + STJACK + STJACK (NEAR) + SWEAT + SWEAT (NEAR) + USD Coin (NEAR) + Tether (NEAR) + Zcash (NEAR) + mpDAO + Meta Pool DAO (NEAR) + nrUsdt + nrUsdt (NEAR) + stNEAR + Staked NEAR (NEAR) + wNEAR + Wrapped NEAR (NEAR) + + + Tether (Toncoin) + + + Tether (TRON) + + + USD Coin (Sui) Maya Error The Maya service is not available. Please try again later. diff --git a/wallet/src/de/schildbach/wallet/data/BuyAndSellDashServicesModel.kt b/wallet/src/de/schildbach/wallet/data/BuyAndSellDashServicesModel.kt index 1c471fff8e..8b47455bd0 100644 --- a/wallet/src/de/schildbach/wallet/data/BuyAndSellDashServicesModel.kt +++ b/wallet/src/de/schildbach/wallet/data/BuyAndSellDashServicesModel.kt @@ -36,7 +36,8 @@ enum class ServiceType( TOPPER(R.string.topper, R.drawable.logo_topper), UPHOLD(R.string.uphold_account, R.drawable.ic_uphold), COINBASE(R.string.coinbase, R.drawable.ic_coinbase), - MAYA(R.string.maya_service_name, R.drawable.ic_maya_logo) + MAYA(R.string.maya_service_name, R.drawable.ic_maya_logo), + SWAPKIT(R.string.swapkit_service_name, R.drawable.ic_swapkit_logo) } @Parcelize @@ -51,7 +52,8 @@ data class BuyAndSellDashServicesModel( BuyAndSellDashServicesModel(ServiceType.TOPPER, ServiceStatus.IDLE), BuyAndSellDashServicesModel(ServiceType.UPHOLD, ServiceStatus.IDLE), BuyAndSellDashServicesModel(ServiceType.COINBASE, ServiceStatus.IDLE), - BuyAndSellDashServicesModel(ServiceType.MAYA, ServiceStatus.IDLE) + BuyAndSellDashServicesModel(ServiceType.MAYA, ServiceStatus.IDLE), + BuyAndSellDashServicesModel(ServiceType.SWAPKIT, ServiceStatus.IDLE) ) } diff --git a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellIntegrationsFragment.kt b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellIntegrationsFragment.kt index 6b69c07e33..946b53683a 100644 --- a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellIntegrationsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellIntegrationsFragment.kt @@ -34,6 +34,7 @@ import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.ui.dialogs.AdaptiveDialog import org.dash.wallet.common.util.openCustomTab import org.dash.wallet.common.util.safeNavigate +import org.dash.wallet.integrations.maya.utils.SwapBackend import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -86,6 +87,11 @@ class BuyAndSellIntegrationsFragment : Fragment() { } }, onMayaClick = { + viewModel.setSwapBackend(SwapBackend.MAYA) + safeNavigate(BuyAndSellIntegrationsFragmentDirections.buySellToMaya()) + }, + onSwapKitClick = { + viewModel.setSwapBackend(SwapBackend.SWAPKIT) safeNavigate(BuyAndSellIntegrationsFragmentDirections.buySellToMaya()) } ) diff --git a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellScreen.kt b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellScreen.kt index 829e569efc..4dc170200a 100644 --- a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellScreen.kt +++ b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellScreen.kt @@ -68,7 +68,8 @@ fun BuyAndSellScreen( onTopperClick: () -> Unit = {}, onUpholdClick: () -> Unit = {}, onCoinbaseClick: () -> Unit = {}, - onMayaClick: () -> Unit = {} + onMayaClick: () -> Unit = {}, + onSwapKitClick: () -> Unit = {} ) { val viewModel: BuyAndSellViewModel = hiltViewModel() @@ -78,7 +79,8 @@ fun BuyAndSellScreen( onTopperClick = onTopperClick, onUpholdClick = onUpholdClick, onCoinbaseClick = onCoinbaseClick, - onMayaClick = onMayaClick + onMayaClick = onMayaClick, + onSwapKitClick = onSwapKitClick ) } @@ -89,7 +91,8 @@ fun BuyAndSellScreen( onTopperClick: () -> Unit = {}, onUpholdClick: () -> Unit = {}, onCoinbaseClick: () -> Unit = {}, - onMayaClick: () -> Unit = {} + onMayaClick: () -> Unit = {}, + onSwapKitClick: () -> Unit = {} ) { val uiState by uiStateFlow.collectAsState() @@ -102,7 +105,8 @@ fun BuyAndSellScreen( onTopperClick = onTopperClick, onUpholdClick = onUpholdClick, onCoinbaseClick = onCoinbaseClick, - onMayaClick = onMayaClick + onMayaClick = onMayaClick, + onSwapKitClick = onSwapKitClick ) } @@ -116,7 +120,8 @@ private fun BuyAndSellScreenContent( onTopperClick: () -> Unit = {}, onUpholdClick: () -> Unit = {}, onCoinbaseClick: () -> Unit = {}, - onMayaClick: () -> Unit = {} + onMayaClick: () -> Unit = {}, + onSwapKitClick: () -> Unit = {} ) { fun serviceOf(type: ServiceType) = services.find { it.serviceType == type } @@ -186,6 +191,18 @@ private fun BuyAndSellScreenContent( } } + // Card 4: SwapKit — same destination as Maya, the backend is + // switched in the click handler. + Menu { + serviceOf(ServiceType.SWAPKIT)?.let { service -> + ServiceItem( + service = service, + balanceFormat = balanceFormat, + onClick = if (service.isAvailable()) onSwapKitClick else null + ) + } + } + if (!hasValidCredentials) { Text( text = stringResource(R.string.services_portal_subtitle_error), @@ -231,7 +248,7 @@ private fun ServiceItem( val subtitle = when (service.serviceStatus) { ServiceStatus.IDLE, ServiceStatus.IDLE_DISCONNECTED -> when (service.serviceType) { ServiceType.TOPPER -> stringResource(R.string.buy_no_account_needed) - ServiceType.MAYA -> stringResource(R.string.convert_no_account_needed) + ServiceType.MAYA, ServiceType.SWAPKIT -> stringResource(R.string.convert_no_account_needed) else -> stringResource(R.string.link_account) } ServiceStatus.CONNECTED -> stringResource(R.string.connected) diff --git a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellViewModel.kt b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellViewModel.kt index a623a5b05b..548d4b00b0 100644 --- a/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/buy_sell/BuyAndSellViewModel.kt @@ -44,6 +44,8 @@ import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.integrations.coinbase.repository.CoinBaseRepository import org.dash.wallet.integrations.coinbase.utils.CoinbaseConfig +import org.dash.wallet.integrations.maya.api.DispatchingSwapProvider +import org.dash.wallet.integrations.maya.utils.SwapBackend import org.dash.wallet.integrations.uphold.api.TopperClient import org.dash.wallet.integrations.uphold.api.UpholdClient import org.dash.wallet.integrations.uphold.api.getDashBalance @@ -74,7 +76,8 @@ class BuyAndSellViewModel @Inject constructor( private val networkState: NetworkStateInt, exchangeRates: ExchangeRatesProvider, private val walletData: WalletDataProvider, - private val walletUIConfig: WalletUIConfig + private val walletUIConfig: WalletUIConfig, + private val swapProvider: DispatchingSwapProvider ): ViewModel() { companion object { @@ -170,6 +173,10 @@ class BuyAndSellViewModel @Inject constructor( hasValidCredentials = true isAuthenticated = false } + ServiceType.SWAPKIT -> { + hasValidCredentials = true + isAuthenticated = false + } } if (!hasValidCredentials) { @@ -271,6 +278,15 @@ class BuyAndSellViewModel @Inject constructor( analytics.logEvent(eventName, mapOf()) } + /** + * Switches the cross-chain swap backend before the user enters the Maya portal + * screens. Both the Maya and SwapKit menu entries flow into the same UI; this + * call decides which backend the ViewModels there will use. + */ + fun setSwapBackend(backend: SwapBackend) { + swapProvider.setBackend(backend) + } + private suspend fun coinbaseBalanceString(): String = Coin.valueOf(coinbaseConfig.get(CoinbaseConfig.LAST_BALANCE) ?: 0).toPlainString() } From 299be321a9eec6c00fadd6ba4f4a652cd877a415 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 16 Jun 2026 13:22:33 -0700 Subject: [PATCH 002/365] feat (dashpay): update "about me" / contact details dialog (#1486) * feat: add ComposeBottomSheet.kt * feat: add new DashPayUserBottomSheet and connect to Contacts and SearchUserFragment.kt * fix: avatar was defaulting to letter image * feat: add coil compose to wallet module * fix: use NavBarClose and fix background color * fix: add filter to bottom sheet * fix: update icons, adjust filter * fix: update the received request card * fix: text and refactor * fix: icons in activity for sent/accept * fix: replace DashPayUserActivity with DashPayUserBottomSheet in other places * fix: defer finish() after showing DashPayUserBottomSheet, drop dead code Calling Activity.finish() immediately after DashPayUserBottomSheet.show() raced with launchWhenResumed in the sheet's show path, risking an IllegalStateException or a silently dropped fragment commit. Post the finish() so the fragment transaction lands first. Also remove now-unused DashPayUserActivity/Intent imports and commented-out startActivity calls left behind by the bottom-sheet migration. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: more minor fixes * fix: expand if necessary, add scrolling * fix: don't finish() while DashPayUserBottomSheet is hosted by the activity The bottom sheet is shown on TransactionResultActivity's own FragmentManager, so finishing the activity right after show() tore the sheet down with it - clicking the contact icon just closed the payment window and revealed the main screen. Icon click: drop the finish() entirely; dismissing the sheet returns to the transaction result screen. Close button with contact data: defer finish() until the sheet is dismissed via FragmentLifecycleCallbacks, mirroring the old finish() + DashPayUserActivity flow. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: coderabbit fixes * fix: master-engineer findings * fix: remove duplicate Grapper.kt causing conflicting Grabber overloads Grapper.kt was a byte-for-byte duplicate of Grabber.kt declaring the same Grabber composable, causing conflicting overloads and overload resolution ambiguity errors in the common module build. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: compile issues after merging to master --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/agents/DEVELOPMENT-PATTERNS.md | 2 +- .../ProfilePictureZoomTransformation.kt | 53 + .../wallet/common/ui/components/DashButton.kt | 3 + .../ui/components/{Grapper.kt => Grabber.kt} | 0 wallet/build.gradle | 1 + .../ic_notification_contact_received.xml | 22 + .../drawable/ic_notification_contact_sent.xml | 22 + wallet/res/values/strings-dashpay.xml | 10 + .../wallet/data/NotificationItemPayment.kt | 4 +- .../wallet/ui/SearchUserFragment.kt | 35 +- .../ui/compose_views/ProfileAvatarCompose.kt | 153 +++ .../wallet/ui/dashpay/ContactsFragment.kt | 13 +- .../ui/dashpay/NotificationsFragment.kt | 21 +- .../ui/dashpay/user/DashPayUserBottomSheet.kt | 982 ++++++++++++++++++ .../user/DashPayUserBottomSheetViewModel.kt | 353 +++++++ .../widget/ContactRequestPaneCompose.kt | 138 +++ .../wallet/ui/invite/InviteDetailsFragment.kt | 4 +- .../ui/main/WalletTransactionsFragment.kt | 9 +- ...hangeTaxCategoryExplainerDialogFragment.kt | 4 +- .../TransactionDetailsDialogFragment.kt | 6 +- .../transactions/TransactionResultActivity.kt | 40 +- .../TransactionResultViewBinder.kt | 8 +- 22 files changed, 1827 insertions(+), 56 deletions(-) create mode 100644 common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt rename common/src/main/java/org/dash/wallet/common/ui/components/{Grapper.kt => Grabber.kt} (100%) create mode 100644 wallet/res/drawable/ic_notification_contact_received.xml create mode 100644 wallet/res/drawable/ic_notification_contact_sent.xml create mode 100644 wallet/src/de/schildbach/wallet/ui/compose_views/ProfileAvatarCompose.kt create mode 100644 wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt create mode 100644 wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheetViewModel.kt create mode 100644 wallet/src/de/schildbach/wallet/ui/dashpay/widget/ContactRequestPaneCompose.kt diff --git a/.claude/agents/DEVELOPMENT-PATTERNS.md b/.claude/agents/DEVELOPMENT-PATTERNS.md index 23211d383b..23032b04b0 100644 --- a/.claude/agents/DEVELOPMENT-PATTERNS.md +++ b/.claude/agents/DEVELOPMENT-PATTERNS.md @@ -770,7 +770,7 @@ A horizontal **amount-input bar** from the design system. Renders, left to right **Figma file:** Design system - Android — node `4414:23352` The component lives in: -``` +```text common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt ``` diff --git a/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt new file mode 100644 index 0000000000..fb4352083e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.common.ui.avatar + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.RectF +import coil.size.Size +import coil.transform.Transformation + +/** + * Coil transformation that crops a bitmap to the normalized zoom rect encoded in a + * dashpay profile picture URL, then scales the result to 300x300. Mirrors the Glide + * transform in [ProfilePictureTransformation] so cached avatars look identical. + */ +class ProfilePictureZoomTransformation(private val zoomedRect: RectF) : Transformation { + + override val cacheKey: String = + "ProfilePictureZoomTransformation(${zoomedRect.left},${zoomedRect.top}," + + "${zoomedRect.right},${zoomedRect.bottom})" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val x = Math.round(zoomedRect.left * input.width).coerceIn(0, input.width - 1) + val y = Math.round(zoomedRect.top * input.height).coerceIn(0, input.height - 1) + val cropWidth = Math.round(input.width * (zoomedRect.right - zoomedRect.left)) + .coerceIn(1, input.width - x) + val cropHeight = Math.round(input.height * (zoomedRect.bottom - zoomedRect.top)) + .coerceIn(1, input.height - y) + if (cropWidth <= 0 || cropHeight <= 0) return input + val zoomX = TARGET / cropWidth + val zoomY = TARGET / cropHeight + val matrix = Matrix().apply { setScale(zoomX, zoomY) } + return Bitmap.createBitmap(input, x, y, cropWidth, cropHeight, matrix, true) + } + + private companion object { + const val TARGET = 300f + } +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt index 850e988c63..7b0d64096e 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt @@ -56,6 +56,7 @@ fun DashButton( style == Style.FilledBlue -> MyTheme.Colors.dashBlue style == Style.FilledOrange -> MyTheme.Colors.orange style == Style.FilledRed -> MyTheme.Colors.red + style == Style.FilledGreen -> Color(0xFF3EB489) style == Style.TintedBlue -> MyTheme.Colors.dashBlue5 style == Style.TintedGray -> Color(0x1AB0B6BC) style == Style.TintedRed -> MyTheme.Colors.red5 @@ -71,6 +72,7 @@ fun DashButton( style == Style.FilledBlue -> Color.White style == Style.FilledOrange -> Color.White style == Style.FilledRed -> Color.White + style == Style.FilledGreen -> Color.White style == Style.TintedBlue -> MyTheme.Colors.dashBlue style == Style.PlainBlue -> MyTheme.Colors.dashBlue style == Style.PlainBlack -> MyTheme.Colors.textPrimary @@ -161,6 +163,7 @@ enum class Style { Filled, FilledBlue, FilledOrange, + FilledGreen, TintedBlue, TintedGray, PlainBlue, diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Grapper.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Grabber.kt similarity index 100% rename from common/src/main/java/org/dash/wallet/common/ui/components/Grapper.kt rename to common/src/main/java/org/dash/wallet/common/ui/components/Grabber.kt diff --git a/wallet/build.gradle b/wallet/build.gradle index 20ef2e8f3b..49553bcadb 100644 --- a/wallet/build.gradle +++ b/wallet/build.gradle @@ -97,6 +97,7 @@ dependencies { ksp "com.github.bumptech.glide:compiler:$glideVersion" implementation 'com.github.MikeOrtiz:TouchImageView:3.6' implementation "io.coil-kt:coil:$coilVersion" + implementation "io.coil-kt:coil-compose:$coilVersion" // Compose implementation(platform("androidx.compose:compose-bom:$composeBom")) diff --git a/wallet/res/drawable/ic_notification_contact_received.xml b/wallet/res/drawable/ic_notification_contact_received.xml new file mode 100644 index 0000000000..fa20eb6b54 --- /dev/null +++ b/wallet/res/drawable/ic_notification_contact_received.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/wallet/res/drawable/ic_notification_contact_sent.xml b/wallet/res/drawable/ic_notification_contact_sent.xml new file mode 100644 index 0000000000..264fab3a79 --- /dev/null +++ b/wallet/res/drawable/ic_notification_contact_sent.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/wallet/res/values/strings-dashpay.xml b/wallet/res/values/strings-dashpay.xml index 795d20f7ac..3e8ac9187f 100644 --- a/wallet/res/values/strings-dashpay.xml +++ b/wallet/res/values/strings-dashpay.xml @@ -104,8 +104,18 @@ There are no users that match Searching for username \"%\" on the Dash Network Contact Request Pending + Send request + Request sent + Send + Once %1$s accepts your request you can pay directly to the username Pay %s has requested to be a friend + %s has sent you a contact request + If you don\'t want %s to be in your contact list you can tap the \"Ignore\" button. They will not be notified about your decision. + %s sent you a contact request + Contact request sent + You accepted the request from %s + %s has accepted your contact request Accept Ignore diff --git a/wallet/src/de/schildbach/wallet/data/NotificationItemPayment.kt b/wallet/src/de/schildbach/wallet/data/NotificationItemPayment.kt index 75ed63c48e..e398435eea 100644 --- a/wallet/src/de/schildbach/wallet/data/NotificationItemPayment.kt +++ b/wallet/src/de/schildbach/wallet/data/NotificationItemPayment.kt @@ -5,6 +5,8 @@ import org.bitcoinj.core.Transaction data class NotificationItemPayment(val tx: Transaction? = null) : NotificationItem() { override fun getId() = tx!!.txId.toString() - override fun getDate() = tx!!.updateTime.time * 1000 + // updateTime.time is already epoch milliseconds; every other NotificationItem returns + // millis too, so the sort key and relative-time display stay consistent across types. + override fun getDate() = tx!!.updateTime.time } diff --git a/wallet/src/de/schildbach/wallet/ui/SearchUserFragment.kt b/wallet/src/de/schildbach/wallet/ui/SearchUserFragment.kt index 9abe1f2722..d365bd3230 100644 --- a/wallet/src/de/schildbach/wallet/ui/SearchUserFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/SearchUserFragment.kt @@ -45,6 +45,7 @@ import de.schildbach.wallet.Constants.USERNAME_MIN_LENGTH import org.dash.wallet.common.data.entity.BlockchainState import de.schildbach.wallet.data.UsernameSearchResult import de.schildbach.wallet.livedata.Status +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.dashpay.DashPayViewModel import de.schildbach.wallet.ui.send.SendCoinsActivity import de.schildbach.wallet_test.R @@ -70,6 +71,17 @@ class SearchUserFragment : Fragment(R.layout.activity_search_dashpay_profile_roo override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // DashPayUserBottomSheet is shown on the activity's FragmentManager (via show(activity)), + // so listen there for its result. + requireActivity().supportFragmentManager.setFragmentResultListener( + DashPayUserBottomSheet.REQUEST_KEY, + viewLifecycleOwner + ) { _, bundle -> + if (bundle.getBoolean(DashPayUserBottomSheet.KEY_CHANGED, false)) { + searchUser(false) + } + } + val toolbar = binding.appBarLayout.toolbar toolbar.setNavigationOnClickListener { findNavController().popBackStack() @@ -269,22 +281,12 @@ class SearchUserFragment : Fragment(R.layout.activity_search_dashpay_profile_roo } override fun onItemClicked(view: View, usernameSearchResult: UsernameSearchResult) { - startActivityForResult(DashPayUserActivity.createIntent(requireContext(), usernameSearchResult), - DashPayUserActivity.REQUEST_CODE_DEFAULT) + // startActivityForResult(DashPayUserActivity.createIntent(requireContext(), usernameSearchResult), + // DashPayUserActivity.REQUEST_CODE_DEFAULT) - //overridePendingTransition(R.anim.slide_in_bottom, R.anim.activity_stay) + DashPayUserBottomSheet.newInstance(usernameSearchResult).show(requireActivity()) } -// override fun onOptionsItemSelected(item: MenuItem): Boolean { -// when (item.itemId) { -// android.R.id.home -> { -// onBackPressed() -// return true -// } -// } -// return super.onOptionsItemSelected(item) -// } - override fun onAcceptRequest(usernameSearchResult: UsernameSearchResult, position: Int) { dashPayViewModel.logEvent(AnalyticsConstants.UsersContacts.ACCEPT_REQUEST) // need to check balance @@ -327,11 +329,4 @@ class SearchUserFragment : Fragment(R.layout.activity_search_dashpay_profile_roo override fun onIgnoreRequest(usernameSearchResult: UsernameSearchResult, position: Int) { } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == DashPayUserActivity.REQUEST_CODE_DEFAULT && resultCode == DashPayUserActivity.RESULT_CODE_CHANGED) { - searchUser(false) - } - } } diff --git a/wallet/src/de/schildbach/wallet/ui/compose_views/ProfileAvatarCompose.kt b/wallet/src/de/schildbach/wallet/ui/compose_views/ProfileAvatarCompose.kt new file mode 100644 index 0000000000..5e84a1699b --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/compose_views/ProfileAvatarCompose.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.schildbach.wallet.ui.compose_views + +import android.net.Uri +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Size +import coil.transform.Transformation +import org.dash.wallet.common.R +import org.dash.wallet.common.ui.avatar.ProfilePictureHelper +import org.dash.wallet.common.ui.avatar.ProfilePictureZoomTransformation +import androidx.core.net.toUri + +/** + * Compose port of [org.dash.wallet.common.ui.avatar.ProfilePictureDisplay]. Loads via Coil with + * the same zoom-rect crop + circle crop, and falls back to a colored-circle-with-initial + * placeholder matching [org.dash.wallet.common.ui.avatar.UserAvatarPlaceholderDrawable]. + * + * Caller controls the size via [modifier] (e.g. `Modifier.size(128.dp)`). + */ +@Composable +fun ProfileAvatar( + avatarUrl: String?, + username: String, + modifier: Modifier = Modifier +) { + val url = avatarUrl?.takeIf { it.isNotEmpty() } + Log.d(LOG_TAG, "compose for $username url=${url ?: ""}") + + Box(modifier = modifier.clip(CircleShape)) { + AvatarPlaceholder(username = username, modifier = Modifier.fillMaxSize()) + + if (url != null) { + val context = LocalContext.current + val parsed = remember(url) { url.toUri() } + val zoomedRect = remember(url) { ProfilePictureHelper.extractZoomedRect(parsed) } + val baseUrl = remember(url) { ProfilePictureHelper.removePicZoomParameter(parsed) } + + val transformations: List = remember(zoomedRect) { + buildList { + zoomedRect?.let { add(ProfilePictureZoomTransformation(it)) } + } + } + + val request = remember(baseUrl, transformations) { + ImageRequest.Builder(context) + .data(baseUrl) + .size(Size.ORIGINAL) + .transformations(transformations) + .crossfade(true) + .listener( + onStart = { Log.d(LOG_TAG, "load start: $baseUrl") }, + onSuccess = { _, _ -> Log.d(LOG_TAG, "load success: $baseUrl") }, + onError = { _, result -> + Log.w(LOG_TAG, "load failed: $baseUrl", result.throwable) + } + ) + .build() + } + + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +private const val LOG_TAG = "ProfileAvatar" + +@Composable +private fun AvatarPlaceholder( + username: String, + modifier: Modifier +) { + val firstChar = username.firstOrNull()?.uppercaseChar() ?: '?' + val bgColor = remember(firstChar) { computeAvatarBackground(firstChar) } + val interRegular = remember { + FontFamily(Font(R.font.inter_regular, FontWeight.Normal)) + } + + BoxWithConstraints( + modifier = modifier + .clip(CircleShape) + .background(bgColor), + contentAlignment = Alignment.Center + ) { + val sizeDp = if (maxWidth < maxHeight) maxWidth else maxHeight + val sizePx = with(LocalDensity.current) { sizeDp.toPx() } + // Mirror UserAvatarPlaceholderDrawable's 30/64 ratio between text and avatar width. + val fontSizeSp = with(LocalDensity.current) { (sizePx * FONT_SIZE_RATIO).toSp() } + Text( + text = firstChar.toString(), + color = Color.White, + style = TextStyle( + fontFamily = interRegular, + fontWeight = FontWeight.Normal, + fontSize = fontSizeSp + ) + ) + } +} + +private const val FONT_SIZE_RATIO: Float = 30f / 64f + +private fun computeAvatarBackground(firstChar: Char): Color { + val ascii = firstChar.code.toFloat() + val charIndex = if (ascii <= 57f) { + // 48 == '0'; 36 == total supported chars (0-9 + A-Z) + (ascii - 48f) / 36f + } else { + (ascii - 65f + 10f) / 36f + } + val hue = charIndex * 360f + return Color.hsv(hue.coerceIn(0f, 360f), 0.3f, 0.6f) +} \ No newline at end of file diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/ContactsFragment.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/ContactsFragment.kt index c856c09667..29aeb66e42 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/ContactsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/ContactsFragment.kt @@ -40,6 +40,7 @@ import de.schildbach.wallet.data.UsernameSearchResult import de.schildbach.wallet.data.UsernameSortOrderBy import de.schildbach.wallet.livedata.Status import de.schildbach.wallet.ui.* +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.main.MainViewModel import de.schildbach.wallet.ui.payments.PaymentsFragment.Companion.ARG_SOURCE import de.schildbach.wallet.ui.send.SendCoinsActivity @@ -92,6 +93,16 @@ class ContactsFragment : Fragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // DashPayUserBottomSheet is shown on the activity's FragmentManager (via show(activity)), + // so listen there for its result. + requireActivity().supportFragmentManager.setFragmentResultListener( + DashPayUserBottomSheet.REQUEST_KEY, + viewLifecycleOwner + ) { _, bundle -> + if (bundle.getBoolean(DashPayUserBottomSheet.KEY_CHANGED, false)) { + searchContacts() + } + } // blockchainIdentity LiveData is populated asynchronously from DataStore. // Reading hasIdentity synchronously can return false before the first // emission, misrouting users who have a username to the EvoUpgrade screen. @@ -401,7 +412,7 @@ class ContactsFragment : Fragment(), override fun onItemClicked(view: View, usernameSearchResult: UsernameSearchResult) { when (args.mode) { ContactsScreenMode.SEARCH_CONTACTS, ContactsScreenMode.VIEW_REQUESTS -> { - startActivity(DashPayUserActivity.createIntent(requireContext(), usernameSearchResult)) + DashPayUserBottomSheet.newInstance(usernameSearchResult).show(requireActivity()) } ContactsScreenMode.SELECT_CONTACT -> { handleString(usernameSearchResult.toContactRequest!!.toUserId, true, R.string.scan_to_pay_username_dialog_message) diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/NotificationsFragment.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/NotificationsFragment.kt index 67641dae95..76f5122637 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/NotificationsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/NotificationsFragment.kt @@ -16,7 +16,6 @@ */ package de.schildbach.wallet.ui.dashpay -import android.content.Intent import android.os.Bundle import android.os.Handler import android.view.View @@ -35,8 +34,8 @@ import de.schildbach.wallet.data.NotificationItemPayment import de.schildbach.wallet.data.NotificationItemUserAlert import de.schildbach.wallet.data.UsernameSearchResult import de.schildbach.wallet.livedata.Status -import de.schildbach.wallet.ui.DashPayUserActivity import de.schildbach.wallet.ui.dashpay.notification.NotificationsViewModel +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.send.SendCoinsActivity import de.schildbach.wallet_test.R import de.schildbach.wallet_test.databinding.FragmentNotificationsBinding @@ -130,6 +129,15 @@ class NotificationsFragment : Fragment(R.layout.fragment_notifications) { if (mode == MODE_NOTIFICATIONS) { dashPayViewModel.logEvent(AnalyticsConstants.UsersContacts.NOTIFICATIONS_HOME_SCREEN) } + + requireActivity().supportFragmentManager.setFragmentResultListener( + DashPayUserBottomSheet.REQUEST_KEY, + viewLifecycleOwner + ) { _, bundle -> + if (bundle.getBoolean(DashPayUserBottomSheet.KEY_CHANGED, false)) { + searchNotifications() + } + } } private fun initViewModel() { @@ -228,7 +236,7 @@ class NotificationsFragment : Fragment(R.layout.fragment_notifications) { is NotificationItemContact -> { dashPayViewModel.logEvent(AnalyticsConstants.UsersContacts.NOTIFICATIONS_CONTACT_DETAILS) val usernameSearchResult = notificationItem.usernameSearchResult - startActivityForResult(DashPayUserActivity.createIntent(requireContext(), usernameSearchResult), DashPayUserActivity.REQUEST_CODE_DEFAULT) + DashPayUserBottomSheet.newInstance(usernameSearchResult).show(requireActivity()) } is NotificationItemPayment -> { val tx = notificationItem.tx!! @@ -298,13 +306,6 @@ class NotificationsFragment : Fragment(R.layout.fragment_notifications) { } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == DashPayUserActivity.REQUEST_CODE_DEFAULT && resultCode == DashPayUserActivity.RESULT_CODE_CHANGED) { - searchNotifications() - } - } - private fun onUserAlertDismiss(alertId: Int) { userAlertItem = null viewModel.dismissUserAlert(alertId) diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt new file mode 100644 index 0000000000..0bf7c68eff --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt @@ -0,0 +1,982 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.ui.dashpay.user + +import android.os.Bundle +import android.text.format.DateUtils +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import dagger.hilt.android.AndroidEntryPoint +import de.schildbach.wallet.data.NotificationItem +import de.schildbach.wallet.data.NotificationItemContact +import de.schildbach.wallet.data.NotificationItemPayment +import de.schildbach.wallet.data.UsernameSearchResult +import de.schildbach.wallet.database.entity.DashPayContactRequest +import de.schildbach.wallet.database.entity.DashPayProfile +import de.schildbach.wallet.livedata.Resource +import de.schildbach.wallet.livedata.Status +import de.schildbach.wallet.ui.compose_views.ProfileAvatar +import de.schildbach.wallet.ui.dashpay.widget.ContactRequestPaneCompose +import de.schildbach.wallet.ui.send.SendCoinsActivity +import de.schildbach.wallet.ui.transactions.TransactionDetailsDialogFragment +import de.schildbach.wallet.ui.util.InputParser +import de.schildbach.wallet_test.R +import kotlinx.coroutines.launch +import org.bitcoinj.core.PrefixedChecksummedBytes +import org.bitcoinj.core.Transaction +import org.bitcoinj.core.VerificationException +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.ui.components.DashButton +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.NavBarClose +import org.dash.wallet.common.ui.components.Style +import org.dash.wallet.common.ui.dialogs.AdaptiveDialog +import org.dash.wallet.common.ui.dialogs.ComposeBottomSheet + +@AndroidEntryPoint +class DashPayUserBottomSheet : ComposeBottomSheet() { + + companion object { + const val REQUEST_KEY = "DashPayUserBottomSheet_request" + const val KEY_CHANGED = "changed" + + private const val ARG_USERNAME_SEARCH_RESULT = "arg_username_search_result" + private const val ARG_DASHPAY_PROFILE = "arg_dashpay_profile" + private const val ARG_SHOW_CONTACT_HISTORY_DISCLAIMER = "arg_show_contact_history_disclaimer" + + fun newInstance( + usernameSearchResult: UsernameSearchResult, + showContactHistoryDisclaimer: Boolean = false + ): DashPayUserBottomSheet { + return DashPayUserBottomSheet().apply { + arguments = bundleOf( + ARG_USERNAME_SEARCH_RESULT to usernameSearchResult, + ARG_SHOW_CONTACT_HISTORY_DISCLAIMER to showContactHistoryDisclaimer + ) + } + } + + fun newInstance(dashPayProfile: DashPayProfile): DashPayUserBottomSheet { + return DashPayUserBottomSheet().apply { + arguments = bundleOf( + ARG_DASHPAY_PROFILE to dashPayProfile, + ARG_SHOW_CONTACT_HISTORY_DISCLAIMER to false + ) + } + } + } + + override val backgroundStyle: Int = R.style.PrimaryBackground + + // Auto-expand is a one-shot decision; once we've expanded for content fit, leave the + // sheet alone so a user-initiated drag isn't overridden by later state updates. Backed + // by Compose state so the content layout can switch to a height-filling, scrollable + // activity list when expanded. + private var hasAutoExpanded by mutableStateOf(false) + + private fun resolveInitialUserData(args: Bundle?): UsernameSearchResult? { + if (args == null) return null + @Suppress("DEPRECATION") + args.getParcelable(ARG_USERNAME_SEARCH_RESULT)?.let { return it } + @Suppress("DEPRECATION") + args.getParcelable(ARG_DASHPAY_PROFILE)?.let { profile -> + return UsernameSearchResult(profile.username, profile, null, null) + } + return null + } + + @Composable + override fun Content() { + val viewModel: DashPayUserBottomSheetViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val initialUserData = remember { resolveInitialUserData(arguments) } + + LaunchedEffect(Unit) { + initialUserData?.let { viewModel.initUserData(it) } + } + + LaunchedEffect(state.userData?.type, state.notifications.size) { + applyAutoExpandIfNeeded(state.userData?.type, state.notifications.size) + } + + DashPayUserContent( + state = state, + isFullScreen = hasAutoExpanded, + onCloseClick = { dismiss() }, + onSendOrAcceptClick = { + lifecycleScope.launch { + handleCreditCheckAndSend(viewModel) + } + }, + onIgnoreClick = { /* not yet implemented, mirror activity */ }, + onPayClick = { + state.userData?.let { startPayActivity(it) } + }, + onNotificationClick = { item -> + if (item is NotificationItemPayment && item.tx != null) { + TransactionDetailsDialogFragment.newInstance(item.tx.txId) + .show(parentFragmentManager, null) + } + }, + onFilterSelected = viewModel::setFilter, + isSentTransaction = viewModel::isSentTransaction + ) + } + + private fun notifyContactChange() { + setFragmentResult(REQUEST_KEY, bundleOf(KEY_CHANGED to true)) + } + + private fun applyAutoExpandIfNeeded( + type: UsernameSearchResult.Type?, + notificationCount: Int + ) { + if (hasAutoExpanded) return + val shouldExpand = when (type) { + UsernameSearchResult.Type.CONTACT_ESTABLISHED -> notificationCount > 3 + UsernameSearchResult.Type.REQUEST_RECEIVED -> notificationCount > 2 + else -> false + } + if (!shouldExpand) return + + val sheet = (dialog as? BottomSheetDialog) + ?.findViewById(com.google.android.material.R.id.design_bottom_sheet) + ?: return + val marginTop = resources.getDimensionPixelSize(R.dimen.offset_dialog_margin_top) + + sheet.layoutParams = (sheet.layoutParams as CoordinatorLayout.LayoutParams).apply { + height = ViewGroup.LayoutParams.MATCH_PARENT + } + BottomSheetBehavior.from(sheet).apply { + expandedOffset = marginTop + state = BottomSheetBehavior.STATE_EXPANDED + } + (sheet.parent as? CoordinatorLayout)?.parent?.requestLayout() + hasAutoExpanded = true + } + + private suspend fun handleCreditCheckAndSend(viewModel: DashPayUserBottomSheetViewModel) { + val activity = requireActivity() + val outcome = viewModel.checkCreditsAndSend() + when (outcome) { + DashPayUserBottomSheetViewModel.CreditCheckOutcome.ShowError -> { + AdaptiveDialog.create( + R.drawable.ic_warning_yellow_circle, + getString(R.string.platform_credits_error), + getString(R.string.platform_communication_error), + getString(R.string.button_ok) + ).showAsync(activity) + viewModel.resetCreditCheck() + } + DashPayUserBottomSheetViewModel.CreditCheckOutcome.ShowWarningEmpty, + DashPayUserBottomSheetViewModel.CreditCheckOutcome.ShowWarningLow -> { + val isEmpty = outcome == DashPayUserBottomSheetViewModel.CreditCheckOutcome.ShowWarningEmpty + val answer = AdaptiveDialog.create( + R.drawable.ic_warning_yellow_circle, + if (isEmpty) getString(R.string.credit_balance_empty_warning_title) + else getString(R.string.credit_balance_low_warning_title), + if (isEmpty) getString(R.string.credit_balance_empty_warning_message) + else getString(R.string.credit_balance_low_warning_message), + getString(R.string.credit_balance_button_maybe_later), + getString(R.string.credit_balance_button_buy) + ).showAsync(activity) + if (answer == true) { + SendCoinsActivity.startBuyCredits(activity) + } else if (!isEmpty) { + viewModel.sendContactRequest() + notifyContactChange() + } + viewModel.resetCreditCheck() + } + DashPayUserBottomSheetViewModel.CreditCheckOutcome.Proceed -> { + viewModel.sendContactRequest() + notifyContactChange() + viewModel.resetCreditCheck() + } + } + } + + private fun startPayActivity(userData: UsernameSearchResult) { + val activity = requireActivity() + object : InputParser.StringInputParser(userData.dashPayProfile.userId, true) { + override fun handlePaymentIntent(paymentIntent: PaymentIntent) { + SendCoinsActivity.start(activity, null, paymentIntent, true) + } + + override fun error(ex: Exception?, messageResId: Int, vararg messageArgs: Any) { + val message = if (messageArgs.isNotEmpty()) { + getString(messageResId, messageArgs) + } else { + getString(messageResId) + } + val dialog = AdaptiveDialog.create( + R.drawable.ic_error, + getString(R.string.scan_to_pay_username_dialog_message), + message, + getString(R.string.button_close), + null + ) + dialog.isMessageSelectable = true + dialog.show(activity) + } + + override fun handlePrivateKey(key: PrefixedChecksummedBytes) { + // ignore + } + + @Throws(VerificationException::class) + override fun handleDirectTransaction(tx: Transaction) { + // ignore + } + }.parse() + dismiss() + } +} + +@Composable +private fun DashPayUserContent( + state: DashPayUserBottomSheetUIState, + isFullScreen: Boolean, + onCloseClick: () -> Unit, + onSendOrAcceptClick: () -> Unit, + onIgnoreClick: () -> Unit, + onPayClick: () -> Unit, + onNotificationClick: (NotificationItem) -> Unit, + onFilterSelected: (NotificationFilter) -> Unit = {}, + isSentTransaction: (Transaction) -> Boolean = { false } +) { + val userData = state.userData + Column( + modifier = Modifier + .fillMaxWidth() + // Only fill height when the sheet has been auto-expanded to MATCH_PARENT; + // otherwise the wrap_content sheet would balloon to full height for every contact. + .then(if (isFullScreen) Modifier.fillMaxHeight() else Modifier) + .background(MyTheme.Colors.backgroundPrimary) + ) { + NavBarClose(onCloseClick = onCloseClick) + + if (userData != null) { + UserInfoCard( + profile = userData.dashPayProfile, + userData = userData, + state = state, + onSendOrAcceptClick = onSendOrAcceptClick, + onPayClick = onPayClick + ) + + if (userData.type == UsernameSearchResult.Type.REQUEST_RECEIVED) { + RequestReceivedCard( + username = userData.dashPayProfile.displayName.ifEmpty { userData.dashPayProfile.username }, + isLoading = state.sendContactRequestState?.status == Status.LOADING, + isNetworkError = state.networkError, + onIgnoreClick = onIgnoreClick, + onAcceptClick = onSendOrAcceptClick + ) + } + } + + // Show the Activity section whenever we have notifications OR a non-default filter is + // active — otherwise selecting "Sent" with no sent items would hide the section and + // strand the dropdown. + if (state.notifications.isNotEmpty() || state.filter != NotificationFilter.ALL) { + ActivitySection( + notifications = state.notifications, + activeFilter = state.filter, + isFullScreen = isFullScreen, + onFilterSelected = onFilterSelected, + onNotificationClick = onNotificationClick, + isSentTransaction = isSentTransaction + ) + } + + if (!isFullScreen) { + Spacer(modifier = Modifier.height(20.dp)) + } + } +} + +@Composable +private fun UserInfoCard( + profile: DashPayProfile, + userData: UsernameSearchResult, + state: DashPayUserBottomSheetUIState, + onSendOrAcceptClick: () -> Unit, + onPayClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MyTheme.Colors.backgroundSecondary) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 40.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileAvatar( + avatarUrl = profile.avatarUrl, + username = profile.username, + modifier = Modifier.size(60.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.username, + style = MyTheme.Typography.TitleMediumMedium, + color = MyTheme.Colors.textPrimary + ) + if (profile.displayName.isNotEmpty()) { + Text( + text = profile.displayName, + style = MyTheme.Typography.LabelMedium, + color = MyTheme.Colors.textTertiary + ) + } + } + } + if (profile.publicMessage.isNotEmpty()) { + Text( + text = profile.publicMessage, + style = MyTheme.Typography.TitleSmall, + color = MyTheme.Colors.textPrimary + ) + } + } + + ContactRequestPaneCompose( + userData = userData, + sendContactRequestState = state.sendContactRequestState, + isNetworkError = state.networkError, + onSendOrAcceptClick = onSendOrAcceptClick, + onPayClick = onPayClick, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun RequestReceivedCard( + username: String, + isLoading: Boolean, + isNetworkError: Boolean, + onIgnoreClick: () -> Unit, + onAcceptClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MyTheme.Colors.backgroundSecondary) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.contact_request_received_card_title, username), + style = MyTheme.Typography.TitleMediumMedium, + color = MyTheme.Colors.textPrimary + ) + Text( + text = stringResource(R.string.contact_request_received_card_message, username), + style = MyTheme.Typography.TitleSmall, + color = MyTheme.Colors.textSecondary + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + DashButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.contact_request_ignore), + style = Style.TintedGray, + isEnabled = !isLoading, + onClick = onIgnoreClick + ) + DashButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.contact_request_accept), + style = Style.FilledGreen, + isEnabled = !isNetworkError, + isLoading = isLoading, + onClick = onAcceptClick + ) + } + } +} + +// CONTACT_ESTABLISHED emits two contact rows for the same user (the established record + an +// "invitationOfEstablished" marker). Both have the same `getId()`, which crashes LazyColumn. +// Compose the flag in so the keys stay unique without touching the shared `getId()` contract +// used by NotificationsAdapter. +private fun NotificationItem.lazyKey(): String = when (this) { + is NotificationItemContact -> "contact:${getId()}:${isInvitationOfEstablished}" + else -> getId() +} + +@Composable +private fun ColumnScope.ActivitySection( + notifications: List, + activeFilter: NotificationFilter, + isFullScreen: Boolean, + onFilterSelected: (NotificationFilter) -> Unit, + onNotificationClick: (NotificationItem) -> Unit, + isSentTransaction: (Transaction) -> Boolean +) { + // In full-screen mode, the section claims all remaining vertical space so the inner + // list can scroll inside it. In wrap_content mode, the section measures to its content + // and the LazyColumn is capped so it stays scrollable rather than ballooning the sheet. + val sectionModifier = if (isFullScreen) { + Modifier + .fillMaxWidth() + .weight(1f, fill = true) + .padding(horizontal = 20.dp, vertical = 10.dp) + } else { + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp) + } + Column( + modifier = sectionModifier, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.notifications_profile_activity), + style = MyTheme.Typography.LabelLarge, + color = MyTheme.Colors.textSecondary + ) + FilterButton( + activeFilter = activeFilter, + onFilterSelected = onFilterSelected + ) + } + val containerModifier = if (isFullScreen) { + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(20.dp)) + .background(MyTheme.Colors.backgroundSecondary) + .padding(6.dp) + } else { + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MyTheme.Colors.backgroundSecondary) + .padding(6.dp) + } + Column(modifier = containerModifier) { + val listModifier = if (isFullScreen) { + Modifier.fillMaxSize() + } else { + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + } + LazyColumn(modifier = listModifier) { + items(notifications, key = { it.lazyKey() }) { item -> + NotificationRow( + item = item, + isSentTransaction = isSentTransaction, + onClick = { onNotificationClick(item) } + ) + } + } + } + } +} + +@Composable +private fun FilterButton( + activeFilter: NotificationFilter, + onFilterSelected: (NotificationFilter) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier + .clip(RoundedCornerShape(11.dp)) + .clickable { expanded = true } + .padding(horizontal = 6.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_filter_icon), + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MyTheme.Colors.textPrimary + ) + Text( + text = stringResource(R.string.activity_buy_and_sell_dash_filter), + style = MyTheme.CaptionMedium, + color = MyTheme.Colors.textPrimary + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MyTheme.Colors.backgroundSecondary) + ) { + FilterMenuItem(R.string.all_transactions, NotificationFilter.ALL, activeFilter) { + onFilterSelected(it); expanded = false + } + FilterMenuItem(R.string.received_transactions, NotificationFilter.RECEIVED, activeFilter) { + onFilterSelected(it); expanded = false + } + FilterMenuItem(R.string.sent_transactions, NotificationFilter.SENT, activeFilter) { + onFilterSelected(it); expanded = false + } + } + } +} + +@Composable +private fun FilterMenuItem( + labelRes: Int, + value: NotificationFilter, + active: NotificationFilter, + onClick: (NotificationFilter) -> Unit +) { + DropdownMenuItem( + text = { + Text( + text = stringResource(labelRes), + style = MyTheme.Typography.BodyMedium, + color = if (value == active) MyTheme.Colors.dashBlue else MyTheme.Colors.textPrimary + ) + }, + trailingIcon = if (value == active) { + { + Icon( + painter = painterResource(R.drawable.ic_checkmark_blue), + contentDescription = null, + tint = MyTheme.Colors.dashBlue, + modifier = Modifier.size(18.dp) + ) + } + } else null, + onClick = { onClick(value) } + ) +} + +@Composable +private fun NotificationRow( + item: NotificationItem, + isSentTransaction: (Transaction) -> Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (item) { + is NotificationItemContact -> { + val profile = item.usernameSearchResult.dashPayProfile + val type = item.usernameSearchResult.type + // Sent (blue) when we initiated the event; received (green) when the counterparty did. + // For CONTACT_ESTABLISHED, the newer request reflects who acted last: if our + // acceptance is newer we sent it (blue); if their acceptance is newer we received it + // (green). The paired invitation-marker row carries the opposite direction, so an + // established contact always shows one of each. + val iconRes = when (type) { + UsernameSearchResult.Type.REQUEST_SENT -> R.drawable.ic_notification_contact_sent + UsernameSearchResult.Type.REQUEST_RECEIVED -> R.drawable.ic_notification_contact_received + UsernameSearchResult.Type.CONTACT_ESTABLISHED -> { + val result = item.usernameSearchResult + val weAccepted = (result.toContactRequest?.timestamp ?: 0L) > + (result.fromContactRequest?.timestamp ?: 0L) + if (weAccepted) R.drawable.ic_notification_contact_sent + else R.drawable.ic_notification_contact_received + } + else -> R.drawable.ic_notification_contact_received + } + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(30.dp) + ) + val name = profile.displayName.ifEmpty { profile.username } + val title = when (type) { + UsernameSearchResult.Type.REQUEST_RECEIVED -> + stringResource(R.string.contact_request_row_received, name) + UsernameSearchResult.Type.REQUEST_SENT -> + stringResource(R.string.contact_request_row_sent) + UsernameSearchResult.Type.CONTACT_ESTABLISHED -> { + // Direction follows toNotificationItems(): toContactRequest newer than + // fromContactRequest means we accepted their earlier request. + val result = item.usernameSearchResult + val incoming = (result.toContactRequest?.timestamp ?: 0L) > + (result.fromContactRequest?.timestamp ?: 0L) + if (incoming) { + stringResource(R.string.contact_request_row_established_incoming, name) + } else { + stringResource(R.string.contact_request_row_established_outgoing, name) + } + } + else -> name + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MyTheme.Typography.TitleSmallMedium, + color = MyTheme.Colors.textPrimary + ) + Text( + text = DateUtils.getRelativeTimeSpanString( + item.getDate(), + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS + ).toString(), + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textSecondary + ) + } + } + is NotificationItemPayment -> { + val tx = item.tx + val sent = tx?.let(isSentTransaction) ?: false + val iconRes = if (sent) R.drawable.ic_transaction_sent else R.drawable.ic_transaction_received + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = androidx.compose.ui.graphics.Color.Unspecified, + modifier = Modifier.size(30.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + if (sent) R.string.transaction_row_status_sent + else R.string.transaction_row_status_received + ), + style = MyTheme.Typography.TitleSmallMedium, + color = MyTheme.Colors.textPrimary + ) + Text( + text = DateUtils.getRelativeTimeSpanString( + item.getDate(), + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS + ).toString(), + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textSecondary + ) + } + val amount = tx?.let { + try { + it.outputs.firstOrNull()?.value?.toFriendlyString() ?: "" + } catch (_: Exception) { "" } + } ?: "" + if (amount.isNotEmpty()) { + Text( + text = amount, + style = MyTheme.Typography.TitleSmallMedium, + color = MyTheme.Colors.textPrimary + ) + } + } + else -> { + Text( + text = item.getId(), + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textSecondary + ) + } + } + } +} + +// ── Previews ─────────────────────────────────────────────────────────────── + +private fun previewProfile() = DashPayProfile( + userId = "preview-user-id", + username = "johndoe", + displayName = "John Doe", + publicMessage = "I am a multi-disciplinary maker of useful, curious and beautiful things.", + avatarUrl = "", + avatarHash = null, + avatarFingerprint = null, + createdAt = 0L, + updatedAt = 0L +) + +private fun previewContactRequest( + userId: String, + toUserId: String, + timestamp: Long = System.currentTimeMillis() - 60_000L +) = DashPayContactRequest( + userId = userId, + toUserId = toUserId, + accountReference = 0, + encryptedPublicKey = ByteArray(0), + senderKeyIndex = 0, + recipientKeyIndex = 0, + timestamp = timestamp, + encryptedAccountLabel = null, + autoAcceptProof = null +) + +private fun previewUserData(type: UsernameSearchResult.Type): UsernameSearchResult { + val profile = previewProfile() + val me = "preview-self-id" + val them = profile.userId + return when (type) { + UsernameSearchResult.Type.NO_RELATIONSHIP -> + UsernameSearchResult(profile.username, profile, null, null) + UsernameSearchResult.Type.REQUEST_SENT -> + UsernameSearchResult(profile.username, profile, previewContactRequest(me, them), null) + UsernameSearchResult.Type.REQUEST_RECEIVED -> + UsernameSearchResult(profile.username, profile, null, previewContactRequest(them, me)) + UsernameSearchResult.Type.CONTACT_ESTABLISHED -> + UsernameSearchResult( + profile.username, + profile, + previewContactRequest(me, them), + previewContactRequest(them, me) + ) + } +} + +private fun previewNotifications(profile: DashPayProfile): List { + val result = UsernameSearchResult( + profile.username, + profile, + previewContactRequest("preview-self-id", profile.userId), + null + ) + return listOf(NotificationItemContact(result)) +} + +@Composable +private fun DashPayUserPreviewFrame(state: DashPayUserBottomSheetUIState) { + DashPayUserContent( + state = state, + isFullScreen = false, + onCloseClick = {}, + onSendOrAcceptClick = {}, + onIgnoreClick = {}, + onPayClick = {}, + onNotificationClick = {} + ) +} + +@Preview(name = "NONE — no relationship", showBackground = true, widthDp = 428, heightDp = 700) +@Composable +private fun PreviewNone() { + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.NO_RELATIONSHIP) + ) + ) +} + +@Preview(name = "INVITING — send pending", showBackground = true, widthDp = 428, heightDp = 700) +@Composable +private fun PreviewInviting() { + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.NO_RELATIONSHIP), + sendContactRequestState = Resource.loading() + ) + ) +} + +@Preview(name = "INVITED — request sent", showBackground = true, widthDp = 428, heightDp = 800) +@Composable +private fun PreviewInvited() { + val profile = previewProfile() + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.REQUEST_SENT), + notifications = previewNotifications(profile) + ) + ) +} + +@Preview(name = "INVITE_RECEIVED", showBackground = true, widthDp = 428, heightDp = 700) +@Composable +private fun PreviewInviteReceived() { + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.REQUEST_RECEIVED) + ) + ) +} + +@Preview(name = "ACCEPTING_INVITE", showBackground = true, widthDp = 428, heightDp = 700) +@Composable +private fun PreviewAcceptingInvite() { + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.REQUEST_RECEIVED), + sendContactRequestState = Resource.loading() + ) + ) +} + +@Preview(name = "FRIENDS — contact established", showBackground = true, widthDp = 428, heightDp = 800) +@Composable +private fun PreviewFriends() { + val profile = previewProfile() + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = previewUserData(UsernameSearchResult.Type.CONTACT_ESTABLISHED), + notifications = previewNotifications(profile) + ) + ) +} + +/** + * Builds the realistic `toNotificationItems` output for CONTACT_ESTABLISHED: + * the established row plus an `isInvitationOfEstablished = true` marker whose + * direction (REQUEST_RECEIVED vs REQUEST_SENT) depends on who sent the request first. + */ +private fun previewEstablishedNotifications( + profile: DashPayProfile, + theirRequestFirst: Boolean +): List { + val me = "preview-self-id" + val them = profile.userId + val now = System.currentTimeMillis() + val firstTs = now - 5 * 60_000L + val secondTs = now - 60_000L + + val (toReq, fromReq) = if (theirRequestFirst) { + // they → me first (fromContactRequest, earlier), then me → them acceptance (toContactRequest, later) + previewContactRequest(me, them, secondTs) to previewContactRequest(them, me, firstTs) + } else { + // me → them first (toContactRequest, earlier), then them → me acceptance (fromContactRequest, later) + previewContactRequest(me, them, firstTs) to previewContactRequest(them, me, secondTs) + } + + val established = UsernameSearchResult(profile.username, profile, toReq, fromReq) + val invitation = if (theirRequestFirst) { + established.copy(toContactRequest = null) // → REQUEST_RECEIVED + } else { + established.copy(fromContactRequest = null) // → REQUEST_SENT + } + return listOf( + NotificationItemContact(established), + NotificationItemContact(invitation, isInvitationOfEstablished = true) + ) +} + +@Preview( + name = "FRIENDS — they sent request first, I accepted", + showBackground = true, + widthDp = 428, + heightDp = 800 +) +@Composable +private fun PreviewFriendsTheySentFirst() { + val profile = previewProfile() + val notifications = previewEstablishedNotifications(profile, theirRequestFirst = true) + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = (notifications.first() as NotificationItemContact).usernameSearchResult, + notifications = notifications + ) + ) +} + +@Preview( + name = "FRIENDS — I sent request first, they accepted", + showBackground = true, + widthDp = 428, + heightDp = 800 +) +@Composable +private fun PreviewFriendsISentFirst() { + val profile = previewProfile() + val notifications = previewEstablishedNotifications(profile, theirRequestFirst = false) + DashPayUserPreviewFrame( + state = DashPayUserBottomSheetUIState( + userData = (notifications.first() as NotificationItemContact).usernameSearchResult, + notifications = notifications + ) + ) +} diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheetViewModel.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheetViewModel.kt new file mode 100644 index 0000000000..3d527181ed --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheetViewModel.kt @@ -0,0 +1,353 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.ui.dashpay.user + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.schildbach.wallet.data.CreditBalanceInfo +import de.schildbach.wallet.data.NotificationItem +import de.schildbach.wallet.data.NotificationItemContact +import de.schildbach.wallet.data.NotificationItemPayment +import de.schildbach.wallet.data.UsernameSearchResult +import de.schildbach.wallet.data.UsernameSortOrderBy +import de.schildbach.wallet.database.dao.BlockchainStateDao +import de.schildbach.wallet.database.entity.DashPayProfile +import de.schildbach.wallet.livedata.Resource +import de.schildbach.wallet.service.DashSystemService +import de.schildbach.wallet.service.platform.IdentityRepository +import de.schildbach.wallet.service.platform.PlatformSyncService +import de.schildbach.wallet.ui.dashpay.PlatformRepo +import de.schildbach.wallet.ui.dashpay.work.SendContactRequestOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dash.wallet.common.WalletDataProvider +import org.dash.wallet.common.data.entity.BlockchainState +import org.dash.wallet.common.services.analytics.AnalyticsConstants +import org.dash.wallet.common.services.analytics.AnalyticsService +import org.dashj.platform.dpp.identifier.Identifier +import org.slf4j.LoggerFactory +import javax.inject.Inject + +enum class NotificationFilter { + ALL, + RECEIVED, + SENT +} + +data class DashPayUserBottomSheetUIState( + val userData: UsernameSearchResult? = null, + val sendContactRequestState: Resource>? = null, + val notifications: List = emptyList(), + val filter: NotificationFilter = NotificationFilter.ALL, + val networkError: Boolean = false, + val creditCheck: DashPayUserBottomSheetViewModel.CreditCheckResult? = null +) + +@HiltViewModel +class DashPayUserBottomSheetViewModel @Inject constructor( + @ApplicationContext private val context: Context, + val platformSyncService: PlatformSyncService, + private val analytics: AnalyticsService, + val platformRepo: PlatformRepo, + val identityRepository: IdentityRepository, + private val dashSystemService: DashSystemService, + private val walletData: WalletDataProvider, + private val blockchainStateDao: BlockchainStateDao +) : ViewModel() { + + companion object { + private val log = LoggerFactory.getLogger(DashPayUserBottomSheetViewModel::class.java) + } + + sealed class CreditCheckResult { + object Loading : CreditCheckResult() + object Error : CreditCheckResult() + data class Insufficient(val empty: Boolean) : CreditCheckResult() + object Sufficient : CreditCheckResult() + } + + enum class CreditCheckOutcome { + ShowError, + ShowWarningEmpty, + ShowWarningLow, + Proceed + } + + private var contactRequestStatusJob: Job? = null + private var initialized = false + + private val _uiState = MutableStateFlow(DashPayUserBottomSheetUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Source-of-truth raw notifications; the displayed `uiState.notifications` is this + // list filtered through `uiState.filter`. Stored separately so the filter can + // re-apply without re-fetching DB contacts. + private val rawNotifications = MutableStateFlow>(emptyList()) + + init { + blockchainStateDao.observeState() + .onEach { state -> + val networkError = state?.impediments?.contains(BlockchainState.Impediment.NETWORK) == true + _uiState.update { it.copy(networkError = networkError) } + } + .catch { ex -> log.error("error observing blockchain state", ex) } + .launchIn(viewModelScope) + + combine(rawNotifications, _uiState.map { it.filter }.distinctUntilChanged()) { raw, filter -> + applyFilter(raw, filter) + } + .onEach { filtered -> _uiState.update { it.copy(notifications = filtered) } } + .launchIn(viewModelScope) + } + + fun setFilter(filter: NotificationFilter) { + _uiState.update { it.copy(filter = filter) } + } + + /** True if [tx] sends value out of this wallet. Used by the activity-row icon picker. */ + fun isSentTransaction(tx: org.bitcoinj.core.Transaction): Boolean = + tx.getValue(walletData.transactionBag).signum() < 0 + + private fun applyFilter(items: List, filter: NotificationFilter): List { + if (filter == NotificationFilter.ALL) return items + val bag = walletData.transactionBag + return items.filter { item -> + when (item) { + is NotificationItemPayment -> { + val tx = item.tx ?: return@filter false + val sent = tx.getValue(bag).signum() < 0 + val isInternal = tx.purpose == org.bitcoinj.core.Transaction.Purpose.KEY_ROTATION + when (filter) { + NotificationFilter.RECEIVED -> !sent && !isInternal + NotificationFilter.SENT -> sent && !isInternal + else -> true + } + } + else -> true + } + } + } + + fun initUserData(userData: UsernameSearchResult) { + if (initialized) return + initialized = true + + _uiState.update { it.copy(userData = userData) } + observeContactNotifications(userData.dashPayProfile) + + viewModelScope.launch { + platformRepo.addOrUpdateDashPayProfile(userData.dashPayProfile) + val username = userData.dashPayProfile.username + + if (userData.toContactRequest == null && userData.fromContactRequest == null) { + try { + platformRepo.getLocalUserDataByUsername(username)?.let { fresh -> + log.info("obtained local user data for $username") + _uiState.update { state -> state.copy(userData = mergePreservingProfileFields(state.userData, fresh)) } + } + } catch (ex: Exception) { + log.error("failed to obtain local user data for $username", ex) + } + } + + try { + identityRepository.getUser(username).firstOrNull()?.let { fresh -> + _uiState.update { state -> state.copy(userData = mergePreservingProfileFields(state.userData, fresh)) } + } + } catch (ex: Exception) { + log.error("Failed to load Profile", ex) + } + + platformRepo.platform.stateRepository.addValidIdentity(userData.dashPayProfile.userIdentifier) + + if (SendContactRequestOperation.hasActiveOperation(context, userData.dashPayProfile.userId)) { + initContactRequestStatusObservation(userData.dashPayProfile.userId) + } + } + } + + fun sendContactRequest() { + val userData = _uiState.value.userData ?: throw IllegalStateException("No user data") + // Mirror the legacy DashPayUserActivity / fragment analytics: accepting an inbound + // request and sending a brand-new one are distinct user actions. + val event = if (userData.type == UsernameSearchResult.Type.REQUEST_RECEIVED) { + AnalyticsConstants.UsersContacts.ACCEPT_REQUEST + } else { + AnalyticsConstants.UsersContacts.SEND_REQUEST + } + analytics.logEvent(event, mapOf()) + SendContactRequestOperation(context) + .create(userData.dashPayProfile.userId) + .enqueue() + + initContactRequestStatusObservation(userData.dashPayProfile.userId) + } + + private fun initContactRequestStatusObservation(userId: String) { + contactRequestStatusJob?.cancel() + contactRequestStatusJob = SendContactRequestOperation.operationStatus( + context, userId, analytics + ).onEach { resource -> + _uiState.update { it.copy(sendContactRequestState = resource) } + }.launchIn(viewModelScope) + } + + suspend fun hasEnoughCredits(): CreditBalanceInfo? { + return identityRepository.getIdentityBalance() + } + + fun getChainLockBlockHeight(): Int { + return dashSystemService.system.chainLockHandler.bestChainLockBlockHeight + } + + suspend fun checkCreditsAndSend(): CreditCheckOutcome { + _uiState.update { it.copy(creditCheck = CreditCheckResult.Loading) } + val enough = hasEnoughCredits() + return if (enough == null) { + _uiState.update { it.copy(creditCheck = CreditCheckResult.Error) } + CreditCheckOutcome.ShowError + } else { + val isEmpty = enough.isBalanceEmpty() + val shouldWarn = enough.isBalanceWarning() + when { + isEmpty -> { + _uiState.update { it.copy(creditCheck = CreditCheckResult.Insufficient(true)) } + CreditCheckOutcome.ShowWarningEmpty + } + shouldWarn -> { + _uiState.update { it.copy(creditCheck = CreditCheckResult.Insufficient(false)) } + CreditCheckOutcome.ShowWarningLow + } + else -> { + _uiState.update { it.copy(creditCheck = CreditCheckResult.Sufficient) } + CreditCheckOutcome.Proceed + } + } + } + } + + fun resetCreditCheck() { + _uiState.update { it.copy(creditCheck = null) } + } + + private fun observeContactNotifications(dashPayProfile: DashPayProfile) { + combine( + identityRepository.observeContacts(dashPayProfile.username, UsernameSortOrderBy.DATE_ADDED, true) + .distinctUntilChanged(), + walletData.observeMostRecentTransaction() + .distinctUntilChanged() + ) { contacts, _ -> + contacts + }.map { toNotificationItems(dashPayProfile.userId, it) } + .onEach { results -> rawNotifications.value = results } + .catch { ex -> + log.error("error while observing contact requests", ex) + } + .launchIn(viewModelScope) + } + + /** + * Platform sometimes returns a partial DashPayProfile (e.g. with an empty avatarUrl) even + * though the local DB and contact-request flow have richer data. Wholesale replacing + * `_uiState.userData` with such a partial profile makes the header avatar revert to the + * placeholder mid-session. Preserve non-empty fields from the current profile. + */ + private fun mergePreservingProfileFields( + current: UsernameSearchResult?, + fresh: UsernameSearchResult + ): UsernameSearchResult { + if (current == null) return fresh + val currentProfile = current.dashPayProfile + val freshProfile = fresh.dashPayProfile + val mergedProfile = freshProfile.copy( + displayName = freshProfile.displayName.ifEmpty { currentProfile.displayName }, + publicMessage = freshProfile.publicMessage.ifEmpty { currentProfile.publicMessage }, + avatarUrl = freshProfile.avatarUrl.ifEmpty { currentProfile.avatarUrl }, + avatarHash = freshProfile.avatarHash ?: currentProfile.avatarHash, + avatarFingerprint = freshProfile.avatarFingerprint ?: currentProfile.avatarFingerprint + ) + return fresh.copy(dashPayProfile = mergedProfile) + } + + suspend fun toNotificationItems(userId: String, contactRequests: List): List { + return withContext(Dispatchers.IO) { + val results = arrayListOf() + var accountReference = 0 + contactRequests.filter { cr -> + cr.dashPayProfile.userId == userId + }.forEach { + val current = _uiState.value.userData + // Refresh _userData when type changes OR when the profile differs (e.g. the + // initial search result had no avatarUrl but the DB now does). Without the + // profile-diff check, the header stays on the stale initial profile while + // the notification rows render the fresh DB-backed one. Merge rather than + // replace so a partial DB profile (e.g. empty avatarUrl) can't revert the + // header avatar that initUserData already resolved — same guarantee as the + // platform/identity refreshes above. + if (current == null || it.type != current.type || it.dashPayProfile != current.dashPayProfile) { + _uiState.update { state -> state.copy(userData = mergePreservingProfileFields(state.userData, it)) } + } + + if (it.type == UsernameSearchResult.Type.REQUEST_RECEIVED) { + results.add(NotificationItemContact(it, true)) + accountReference = it.fromContactRequest!!.accountReference + } else { + results.add(NotificationItemContact(it)) + } + if (it.type == UsernameSearchResult.Type.CONTACT_ESTABLISHED) { + val incoming = (it.toContactRequest!!.timestamp > it.fromContactRequest!!.timestamp) + val invitationItem = + if (incoming) it.copy(toContactRequest = null) else it.copy(fromContactRequest = null) + results.add(NotificationItemContact(invitationItem, isInvitationOfEstablished = true)) + accountReference = it.fromContactRequest!!.accountReference + } + } + + val blockchainIdentity = identityRepository.blockchainIdentity ?: run { + log.warn("blockchainIdentity is null, cannot get contact transactions") + return@withContext emptyList() + } + val txs = blockchainIdentity.getContactTransactions(Identifier.from(userId), accountReference) + + txs.forEach { + results.add(NotificationItemPayment(it)) + } + + val sortedResults = results.sortedWith( + compareByDescending { item: NotificationItem -> item.getDate() }.thenBy { item: NotificationItem -> item.getId() } + ) + + return@withContext sortedResults + } + } +} \ No newline at end of file diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/widget/ContactRequestPaneCompose.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/widget/ContactRequestPaneCompose.kt new file mode 100644 index 0000000000..d801816b5b --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/widget/ContactRequestPaneCompose.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.schildbach.wallet.ui.dashpay.widget + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.schildbach.wallet.data.UsernameSearchResult +import de.schildbach.wallet.livedata.Resource +import de.schildbach.wallet.livedata.Status +import de.schildbach.wallet.ui.ContactRelation +import de.schildbach.wallet_test.R +import org.dash.wallet.common.ui.components.DashButton +import org.dash.wallet.common.ui.components.MyImages +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.Style + +/** + * The action area (button + optional sub-disclaimer) of the DashPay user bottom sheet. + * Lives inside the parent's white user-info card; doesn't draw its own background. + */ +@Composable +fun ContactRequestPaneCompose( + userData: UsernameSearchResult, + sendContactRequestState: Resource<*>?, + isNetworkError: Boolean, + onSendOrAcceptClick: () -> Unit, + onPayClick: () -> Unit, + modifier: Modifier = Modifier +) { + val relationship = resolveRelationship(userData.type, sendContactRequestState) + val username = userData.username + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + when (relationship) { + ContactRelation.Relationship.NONE -> + DashButton( + text = stringResource(R.string.send_contact_request_short), + style = Style.FilledBlue, + isEnabled = !isNetworkError, + onClick = onSendOrAcceptClick + ) + + ContactRelation.Relationship.INVITING -> + DashButton( + text = null, + style = Style.FilledBlue, + isLoading = true, + onClick = {} + ) + + ContactRelation.Relationship.INVITED -> + DashButton( + text = stringResource(R.string.contact_request_sent_short), + style = Style.FilledBlue, + isEnabled = false, + onClick = {} + ) + + ContactRelation.Relationship.INVITE_RECEIVED, + ContactRelation.Relationship.ACCEPTING_INVITE, + ContactRelation.Relationship.FRIENDS -> + DashButton( + text = stringResource(R.string.send_button_label), + leadingIcon = MyImages.DashDWhite, + style = Style.FilledBlue, + onClick = onPayClick + ) + } + + if (relationship.showsPendingDisclaimer()) { + DisclaimerText( + text = stringResource(R.string.contact_history_disclaimer_pending_plain, username) + ) + } + } +} + +private fun ContactRelation.Relationship.showsPendingDisclaimer(): Boolean = when (this) { + ContactRelation.Relationship.NONE, + ContactRelation.Relationship.INVITING, + ContactRelation.Relationship.INVITED -> true + else -> false +} + +@Composable +private fun DisclaimerText(text: String) { + Text( + text = text, + style = MyTheme.Typography.LabelMedium, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.fillMaxWidth() + ) +} + +private fun resolveRelationship( + type: UsernameSearchResult.Type, + state: Resource<*>? +): ContactRelation.Relationship { + return when (type) { + UsernameSearchResult.Type.NO_RELATIONSHIP -> when (state?.status) { + null -> ContactRelation.Relationship.NONE + Status.LOADING -> ContactRelation.Relationship.INVITING + Status.SUCCESS -> ContactRelation.Relationship.INVITED + else -> ContactRelation.Relationship.NONE + } + UsernameSearchResult.Type.REQUEST_SENT -> ContactRelation.Relationship.INVITED + UsernameSearchResult.Type.REQUEST_RECEIVED -> when (state?.status) { + null -> ContactRelation.Relationship.INVITE_RECEIVED + Status.LOADING -> ContactRelation.Relationship.ACCEPTING_INVITE + Status.SUCCESS -> ContactRelation.Relationship.FRIENDS + else -> ContactRelation.Relationship.INVITE_RECEIVED + } + UsernameSearchResult.Type.CONTACT_ESTABLISHED -> ContactRelation.Relationship.FRIENDS + } +} \ No newline at end of file diff --git a/wallet/src/de/schildbach/wallet/ui/invite/InviteDetailsFragment.kt b/wallet/src/de/schildbach/wallet/ui/invite/InviteDetailsFragment.kt index 1f0080753f..2ff92cfa49 100644 --- a/wallet/src/de/schildbach/wallet/ui/invite/InviteDetailsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/invite/InviteDetailsFragment.kt @@ -30,7 +30,7 @@ import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint import de.schildbach.wallet.database.entity.Invitation -import de.schildbach.wallet.ui.DashPayUserActivity +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.dashpay.utils.display import de.schildbach.wallet.util.WalletUtils import de.schildbach.wallet_test.R @@ -98,7 +98,7 @@ class InviteDetailsFragment : InvitationFragment(R.layout.fragment_invite_detail val profile = viewModel.getInvitedUserProfile() if (profile != null) { - startActivity(DashPayUserActivity.createIntent(requireContext(), profile)) + DashPayUserBottomSheet.newInstance(profile).show(requireActivity()) } else { /* not sure why this is happening */ AdaptiveDialog.create( diff --git a/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt b/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt index 6b93a2f80a..0ab49bfaa5 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt @@ -30,7 +30,6 @@ import android.widget.Toast import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ConcatAdapter import de.schildbach.wallet.ui.CreateUsernameActivity -import de.schildbach.wallet.ui.DashPayUserActivity import de.schildbach.wallet.ui.LockScreenActivity import de.schildbach.wallet.ui.dashpay.CreateIdentityService import de.schildbach.wallet.ui.dashpay.HistoryHeaderAdapter @@ -51,6 +50,7 @@ import de.schildbach.wallet.data.InvitationValidationState import de.schildbach.wallet.service.platform.IdentityRepository import de.schildbach.wallet.service.platform.work.RestoreIdentityOperation import de.schildbach.wallet.ui.InviteHandlerViewModel +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.registerLockScreenDeactivated import de.schildbach.wallet.ui.transactions.TransactionDetailsDialogFragment import de.schildbach.wallet.ui.transactions.TransactionGroupDetailsFragment @@ -107,12 +107,7 @@ class WalletTransactionsFragment : Fragment(R.layout.wallet_transactions_fragmen viewLifecycleOwner.lifecycleScope.launch { if (rowView is TransactionRowView) { if (isProfileClick && rowView.contact != null) { - requireContext().startActivity( - DashPayUserActivity.createIntent( - requireContext(), - rowView.contact - ) - ) + DashPayUserBottomSheet.newInstance(rowView.contact).show(requireActivity()) } else { // For rows loaded from the display cache, txWrapper is null. // Fall back to the live wrapper list so CoinJoin/CrowdNode groups still open. diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/ChangeTaxCategoryExplainerDialogFragment.kt b/wallet/src/de/schildbach/wallet/ui/transactions/ChangeTaxCategoryExplainerDialogFragment.kt index 9b7662ba74..9b659102dd 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/ChangeTaxCategoryExplainerDialogFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/ChangeTaxCategoryExplainerDialogFragment.kt @@ -74,7 +74,9 @@ class ChangeTaxCategoryExplainerDialogFragment : OffsetDialogFragment(R.layout.d wallet, config.format.noCode(), contentBinding - ) + ) { + error("ChangeTaxCategoryExplainer binds with a null profile; openProfile should never fire") + } tx?.apply { transactionResultViewBinder.bind(this, null) transactionResultViewBinder.setTransactionMetadata( diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt index cf184c4c37..2b10935f2a 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt @@ -30,6 +30,7 @@ import de.schildbach.wallet.service.platform.work.TopupIdentityWorker import de.schildbach.wallet.ui.TransactionResultViewModel import de.schildbach.wallet.ui.compose_views.ComposeBottomSheet import de.schildbach.wallet.ui.dashpay.transactions.PrivateMemoDialog +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.more.ContactSupportDialogFragment import de.schildbach.wallet.ui.util.viewOnBlockExplorer import org.dash.wallet.common.UserInteractionAwareCallback @@ -99,7 +100,10 @@ class TransactionDetailsDialogFragment : OffsetDialogFragment(R.layout.transacti viewModel.wallet!!, viewModel.dashFormat, contentBinding - ) + ) { + DashPayUserBottomSheet.newInstance(it).show(requireActivity()) + dismissAllowingStateLoss() + } viewModel.init(txId) viewModel.transaction.filterNotNull().observe(viewLifecycleOwner) { tx -> diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt index cda6e51548..4b289a02b9 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt @@ -24,10 +24,11 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import de.schildbach.wallet.ui.main.MainActivity import de.schildbach.wallet.data.UsernameSearchResult -import de.schildbach.wallet.ui.DashPayUserActivity import de.schildbach.wallet.ui.dashpay.transactions.PrivateMemoDialog import dagger.hilt.android.AndroidEntryPoint import de.schildbach.wallet.database.dao.DashPayProfileDao @@ -36,6 +37,7 @@ import de.schildbach.wallet.service.platform.work.TopupIdentityWorker import de.schildbach.wallet.ui.LockScreenActivity import de.schildbach.wallet.ui.TransactionResultViewModel import de.schildbach.wallet.ui.compose_views.ComposeBottomSheet +import de.schildbach.wallet.ui.dashpay.user.DashPayUserBottomSheet import de.schildbach.wallet.ui.more.ContactSupportDialogFragment import de.schildbach.wallet.ui.send.SendCoinsActivity import de.schildbach.wallet.ui.util.viewOnBlockExplorer @@ -150,7 +152,12 @@ class TransactionResultActivity : LockScreenActivity() { walletData.wallet!!, configuration.format.noCode(), contentBinding - ) + ) { + // The sheet is hosted by this activity's FragmentManager, so don't finish() here — + // that would tear the sheet down along with the activity. Dismissing the sheet + // returns the user to this transaction result screen. + DashPayUserBottomSheet.newInstance(it).show(this) + } viewModel.init(txId) @@ -286,10 +293,11 @@ class TransactionResultActivity : LockScreenActivity() { finish() } userData != null -> { - finish() - startActivity( - DashPayUserActivity.createIntent(this@TransactionResultActivity, - userData!!, userData != null)) + // The sheet is hosted by this activity, so finishing right away would destroy + // it too. Defer finish() until the sheet is dismissed, mirroring the old + // finish() + DashPayUserActivity flow. + finishWhenUserSheetDismissed() + DashPayUserBottomSheet.newInstance(userData!!).show(this) } intent.getBooleanExtra(EXTRA_USER_AUTHORIZED_RESULT_EXTRA, false) -> { startActivity(MainActivity.createIntent(this)) @@ -300,6 +308,26 @@ class TransactionResultActivity : LockScreenActivity() { } } + /** + * Finish this host activity once the [DashPayUserBottomSheet] is genuinely dismissed. + * [onFragmentDestroyed] also fires on configuration changes (e.g. rotation), when the sheet + * is immediately recreated — finishing then would tear down the screen out from under the + * user, so we skip that case via [isChangingConfigurations]. + */ + private fun finishWhenUserSheetDismissed() { + supportFragmentManager.registerFragmentLifecycleCallbacks( + object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentDestroyed(fm: FragmentManager, fragment: Fragment) { + if (fragment is DashPayUserBottomSheet && !isChangingConfigurations) { + fm.unregisterFragmentLifecycleCallbacks(this) + finish() + } + } + }, + false + ) + } + override fun onDestroy() { super.onDestroy() viewModel.transaction.value?.confidence?.removeEventListener(transactionResultViewBinder) diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt index d52b0a7adf..b16a8e7851 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt @@ -32,7 +32,6 @@ import coil.load import coil.transform.RoundedCornersTransformation import de.schildbach.wallet.Constants import de.schildbach.wallet.database.entity.DashPayProfile -import de.schildbach.wallet.ui.DashPayUserActivity import de.schildbach.wallet_test.R import de.schildbach.wallet_test.databinding.TransactionResultContentBinding import org.bitcoinj.core.Address @@ -56,7 +55,8 @@ import org.dash.wallet.common.util.makeLinks class TransactionResultViewBinder( private val wallet: Wallet, private val dashFormat: MonetaryFormat, - private val binding: TransactionResultContentBinding + private val binding: TransactionResultContentBinding, + private val openProfile: (DashPayProfile) -> Unit ): TransactionConfidence.Listener { private val iconSize = binding.root.context.resources.getDimensionPixelSize(R.dimen.transaction_details_icon_size) private val context by lazy { binding.root.context } @@ -374,10 +374,6 @@ class TransactionResultViewBinder( return transactionFee != null && transactionFee.isPositive } - private fun openProfile(profile: DashPayProfile) { - context.startActivity(DashPayUserActivity.createIntent(context, profile)) - } - private fun setInputs(inputAddresses: List
, inflater: LayoutInflater) { binding.inputsContainer.isVisible = inputAddresses.isNotEmpty() inputAddresses.forEach { From 4c9494de3d91b9920a49c17e4ad9b21a7d7751d7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 28 Jun 2026 07:20:48 -0700 Subject: [PATCH 003/365] feat: update about-me activity list UI to designs (#1500) * feat: group transactions on DashPayUserBottomSheet * fix: support scrolling * fix: onDispose fix --- .../ui/dashpay/user/DashPayUserBottomSheet.kt | 209 +++++++++++++----- 1 file changed, 155 insertions(+), 54 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt index 0bf7c68eff..fbe2baf5b5 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height @@ -38,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu @@ -45,7 +45,9 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,6 +56,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -92,6 +95,11 @@ import org.dash.wallet.common.ui.components.NavBarClose import org.dash.wallet.common.ui.components.Style import org.dash.wallet.common.ui.dialogs.AdaptiveDialog import org.dash.wallet.common.ui.dialogs.ComposeBottomSheet +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale @AndroidEntryPoint class DashPayUserBottomSheet : ComposeBottomSheet() { @@ -179,7 +187,8 @@ class DashPayUserBottomSheet : ComposeBottomSheet() { } }, onFilterSelected = viewModel::setFilter, - isSentTransaction = viewModel::isSentTransaction + isSentTransaction = viewModel::isSentTransaction, + onSheetDraggableChanged = ::setSheetDraggable ) } @@ -187,6 +196,17 @@ class DashPayUserBottomSheet : ComposeBottomSheet() { setFragmentResult(REQUEST_KEY, bundleOf(KEY_CHANGED to true)) } + // The activity list is a Compose LazyColumn nested inside a Material BottomSheetDialog. + // BottomSheetBehavior's drag and the list's scroll both want vertical gestures, so we let the + // list own them while it has content above (drag locked) and only re-enable the sheet drag + // (so a downward swipe collapses/dismisses) once the list is scrolled to its very top. + private fun setSheetDraggable(draggable: Boolean) { + val sheet = (dialog as? BottomSheetDialog) + ?.findViewById(com.google.android.material.R.id.design_bottom_sheet) + ?: return + BottomSheetBehavior.from(sheet).isDraggable = draggable + } + private fun applyAutoExpandIfNeeded( type: UsernameSearchResult.Type?, notificationCount: Int @@ -303,7 +323,8 @@ private fun DashPayUserContent( onPayClick: () -> Unit, onNotificationClick: (NotificationItem) -> Unit, onFilterSelected: (NotificationFilter) -> Unit = {}, - isSentTransaction: (Transaction) -> Boolean = { false } + isSentTransaction: (Transaction) -> Boolean = { false }, + onSheetDraggableChanged: (Boolean) -> Unit = {} ) { val userData = state.userData Column( @@ -346,7 +367,8 @@ private fun DashPayUserContent( isFullScreen = isFullScreen, onFilterSelected = onFilterSelected, onNotificationClick = onNotificationClick, - isSentTransaction = isSentTransaction + isSentTransaction = isSentTransaction, + onSheetDraggableChanged = onSheetDraggableChanged ) } @@ -481,15 +503,6 @@ private fun RequestReceivedCard( } } -// CONTACT_ESTABLISHED emits two contact rows for the same user (the established record + an -// "invitationOfEstablished" marker). Both have the same `getId()`, which crashes LazyColumn. -// Compose the flag in so the keys stay unique without touching the shared `getId()` contract -// used by NotificationsAdapter. -private fun NotificationItem.lazyKey(): String = when (this) { - is NotificationItemContact -> "contact:${getId()}:${isInvitationOfEstablished}" - else -> getId() -} - @Composable private fun ColumnScope.ActivitySection( notifications: List, @@ -497,7 +510,8 @@ private fun ColumnScope.ActivitySection( isFullScreen: Boolean, onFilterSelected: (NotificationFilter) -> Unit, onNotificationClick: (NotificationItem) -> Unit, - isSentTransaction: (Transaction) -> Boolean + isSentTransaction: (Transaction) -> Boolean, + onSheetDraggableChanged: (Boolean) -> Unit = {} ) { // In full-screen mode, the section claims all remaining vertical space so the inner // list can scroll inside it. In wrap_content mode, the section measures to its content @@ -531,40 +545,123 @@ private fun ColumnScope.ActivitySection( onFilterSelected = onFilterSelected ) } - val containerModifier = if (isFullScreen) { + // Group the (already date-sorted) notifications by calendar day; each day renders as its + // own rounded card with a header (date label on the left, weekday on the right). + val groups = remember(notifications) { groupNotificationsByDay(notifications) } + val listModifier = if (isFullScreen) { Modifier - .fillMaxSize() - .clip(RoundedCornerShape(20.dp)) - .background(MyTheme.Colors.backgroundSecondary) - .padding(6.dp) + .fillMaxWidth() + .weight(1f, fill = true) } else { Modifier .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(MyTheme.Colors.backgroundSecondary) - .padding(6.dp) + .heightIn(max = 500.dp) } - Column(modifier = containerModifier) { - val listModifier = if (isFullScreen) { - Modifier.fillMaxSize() - } else { - Modifier - .fillMaxWidth() - .heightIn(max = 500.dp) + // Let the list own vertical gestures while it has content scrolled above the top, and only + // hand the sheet back its drag (so a downward swipe can collapse/dismiss) once the list is + // resting at its very top. This avoids the sheet and the LazyColumn fighting over the drag. + val listState = rememberLazyListState() + val listAtTop by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } - LazyColumn(modifier = listModifier) { - items(notifications, key = { it.lazyKey() }) { item -> - NotificationRow( - item = item, - isSentTransaction = isSentTransaction, - onClick = { onNotificationClick(item) } - ) - } + } + LaunchedEffect(listAtTop) { + onSheetDraggableChanged(listAtTop) + } + DisposableEffect(Unit) { + onDispose { onSheetDraggableChanged(true) } + } + LazyColumn( + state = listState, + modifier = listModifier, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(groups, key = { it.date.toString() }) { group -> + DayGroupCard( + group = group, + isSentTransaction = isSentTransaction, + onNotificationClick = onNotificationClick + ) } } } } +// A day's worth of activity items, used to render grouped, date-headed cards. +private data class NotificationDayGroup( + val date: LocalDate, + val items: List +) + +// groupBy preserves the encounter order of both keys and values, so an input that is already +// sorted newest-first stays newest-first; the trailing sort is a defensive no-op. +private fun groupNotificationsByDay(items: List): List { + val zone = ZoneId.systemDefault() + return items + .groupBy { Instant.ofEpochMilli(it.getDate()).atZone(zone).toLocalDate() } + .map { (date, dayItems) -> NotificationDayGroup(date, dayItems) } + .sortedByDescending { it.date } +} + +/** "Today", "Yesterday", or a locale-ordered date ("2 May" / "May 2"), with the year when not current. */ +@Composable +private fun dayLabel(date: LocalDate): String { + val now = LocalDate.now() + return when { + date == now -> stringResource(R.string.today) + date == now.minusDays(1) -> stringResource(R.string.yesterday) + else -> { + val locale = Locale.getDefault() + val skeleton = if (date.year == now.year) "MMMMd" else "yMMMMd" + val pattern = android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton) + DateTimeFormatter.ofPattern(pattern, locale).format(date) + } + } +} + +@Composable +private fun DayGroupCard( + group: NotificationDayGroup, + isSentTransaction: (Transaction) -> Boolean, + onNotificationClick: (NotificationItem) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MyTheme.Colors.backgroundSecondary) + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = dayLabel(group.date), + style = MyTheme.CaptionMedium, + color = MyTheme.Colors.textPrimary + ) + Text( + text = DateTimeFormatter.ofPattern("EEEE", Locale.getDefault()).format(group.date), + style = MyTheme.Caption, + color = MyTheme.Colors.textSecondary + ) + } + group.items.forEach { item -> + NotificationRow( + item = item, + isSentTransaction = isSentTransaction, + onClick = { onNotificationClick(item) } + ) + } + } +} + @Composable private fun FilterButton( activeFilter: NotificationFilter, @@ -645,6 +742,10 @@ private fun NotificationRow( isSentTransaction: (Transaction) -> Boolean, onClick: () -> Unit ) { + // The day is conveyed by the group header; rows show only the time of day (e.g. "9:40 AM"), + // localized and honoring the system 12/24-hour setting. + val context = LocalContext.current + val timeText = DateUtils.formatDateTime(context, item.getDate(), DateUtils.FORMAT_SHOW_TIME) Row( modifier = Modifier .fillMaxWidth() @@ -707,11 +808,7 @@ private fun NotificationRow( color = MyTheme.Colors.textPrimary ) Text( - text = DateUtils.getRelativeTimeSpanString( - item.getDate(), - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS - ).toString(), + text = timeText, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textSecondary ) @@ -737,11 +834,7 @@ private fun NotificationRow( color = MyTheme.Colors.textPrimary ) Text( - text = DateUtils.getRelativeTimeSpanString( - item.getDate(), - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS - ).toString(), + text = timeText, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textSecondary ) @@ -822,13 +915,21 @@ private fun previewUserData(type: UsernameSearchResult.Type): UsernameSearchResu } private fun previewNotifications(profile: DashPayProfile): List { - val result = UsernameSearchResult( - profile.username, - profile, - previewContactRequest("preview-self-id", profile.userId), - null - ) - return listOf(NotificationItemContact(result)) + val me = "preview-self-id" + val them = profile.userId + val dayMillis = 24L * 60 * 60 * 1000 + val now = System.currentTimeMillis() + // Three sent requests spread across today, yesterday and a few days back so the + // day-grouping (Today / Yesterday / dated) is exercised by the preview. + return listOf( + now - 60_000L, + now - dayMillis, + now - 5 * dayMillis + ).map { ts -> + NotificationItemContact( + UsernameSearchResult(profile.username, profile, previewContactRequest(me, them, ts), null) + ) + } } @Composable From c1baf29cc39df6523029ba8036d9f0bb54230eb7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 29 Jun 2026 14:15:58 -0700 Subject: [PATCH 004/365] feat(dex): rename swapkit to Dash DEX and improve sell swap functionality (#1493) * fix: remove midgard as a data source * fix: use the USD pools to determine the USD price of all other currencies * chore: remove commented code * fix: add strings-maya to .tx/config * fix: add new error text * fix: update documentation and agents * feat: start SwapKit proof of concept * fix: repair many things * fix: add provider information to preview screen, fix other issues * fix: update the supported currency list to include NEAR supported currencies * fix: udpate portal screen to show the right name and icon * fix: handle no routes error due to low swap value * fix: crash from click after search * fix: put API_KEY into the service.properties file * fix: remove dead code * fix: fee calculation * fix: add swapkit model classes to proguard-rules.pro * fix: handle errors better and improve the swap call * fix: add edge-to-edge MayaResultDialog * fix: exchange rates in other currencies * feat: hide native Maya entry point on Buy and Sell screen * fix: compile issues and default args * fix: rename swapkit to Dash DEX * feat: use maya API to determine if maya only coins are "chain halted" * chore: add NEAR Intents protocol summary * feat: align currency picker screen * feat: obtain preferred provider * feat: obtain preferred provider * feat: add cache for crypto currency picker screen * feat: add primary source for cryptocurrency icons, first for the crypto picker * feat: add placeholder icon * tests: add tests for MayaCryptoCurrency to verify example addresses * feat: add swap provider to the order preview screen * refactor: move SearchField to common components and align with Figma designs * fix: hide debug information about which provider will be used * fix: prevent changing the numbers after pressing continue * docs: add Maya liquidity provider information * fix: update cryptocurrency list to match designs * fix: refactor the CoinRow in the currency list to use the CoinSelect component * fix: add the using %s network * fix: fix many issues from CodeRabbit * fix: add taproot example addresses * fix: use SwapKit icon url * fix: fix merge compile issues * fix: handle NEAR swaps and avoid crashes * fix: properly handle NEAR fees (include them) and set max slippage to 2% --- .../wallet/common/ui/components/CoinSelect.kt | 285 +++++++++++ .../common/ui/components/SearchField.kt | 174 +++++++ .../dash/wallet/common/util/GenericUtils.kt | 33 +- common/src/main/res/values/strings.xml | 1 + .../payments/parsers/AddressParserTest.kt | 2 + integrations/maya/MAYA_LIQUIDITY_PROVIDERS.md | 265 ++++++++++ integrations/maya/NEAR_INTENTS_PROTOCOL.md | 418 ++++++++++++++++ integrations/maya/SWAPKIT_PROTOCOL.md | 57 +++ integrations/maya/build.gradle | 1 + .../maya/api/DispatchingSwapProvider.kt | 23 +- .../wallet/integrations/maya/api/MayaApi.kt | 5 +- .../maya/api/MayaBlockchainApi.kt | 23 + .../integrations/maya/api/RouteProvider.kt | 30 ++ .../integrations/maya/api/SwapProvider.kt | 20 +- .../wallet/integrations/maya/di/MayaModule.kt | 3 +- .../integrations/maya/model/PoolInfo.kt | 31 ++ .../maya/model/SwapTradeResponse.kt | 2 +- .../maya/payments/MayaCryptoCurrency.kt | 4 +- .../maya/swapkit/SwapKitApiAggregator.kt | 468 ++++++++++++++++-- .../maya/swapkit/SwapKitAuthInterceptor.kt | 2 +- .../maya/swapkit/SwapKitConstants.kt | 15 +- .../maya/swapkit/SwapKitEndpoint.kt | 2 +- .../maya/swapkit/SwapKitWebApi.kt | 2 +- .../maya/swapkit/model/SwapKitModels.kt | 2 +- .../maya/ui/MayaAddressInputViewModel.kt | 34 +- .../maya/ui/MayaConversionPreviewFragment.kt | 45 +- .../maya/ui/MayaConvertCryptoFragment.kt | 28 +- .../maya/ui/MayaConvertCryptoViewModel.kt | 4 +- .../ui/MayaCryptoCurrencyPickerFragment.kt | 201 ++------ .../maya/ui/MayaCryptoCurrencyPickerScreen.kt | 416 ++++++++++++++++ .../integrations/maya/ui/MayaViewModel.kt | 185 ++++++- .../convert_currency/ConvertViewFragment.kt | 29 +- .../integrations/maya/utils/MayaConfig.kt | 9 + .../integrations/maya/utils/SwapBackend.kt | 2 +- .../main/res/drawable/ic_coin_placeholder.xml | 12 + .../content_conversion_preview_maya.xml | 44 +- .../layout/fragment_convert_currency_view.xml | 24 +- .../res/layout/fragment_currency_picker.xml | 133 ----- .../layout/fragment_maya_convert_crypto.xml | 13 + .../maya/src/main/res/navigation/nav_maya.xml | 3 +- .../maya/src/main/res/values/strings-maya.xml | 16 +- .../maya/payments/MayaCryptoCurrencyTest.kt | 81 +++ .../payments/parsers/AddressParserTest.kt | 271 ++++++++++ .../src/de/schildbach/wallet/Constants.java | 2 + .../data/BuyAndSellDashServicesModel.kt | 1 - .../wallet/ui/buy_sell/BuyAndSellScreen.kt | 33 +- 46 files changed, 3030 insertions(+), 424 deletions(-) create mode 100644 common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt create mode 100644 common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt create mode 100644 integrations/maya/MAYA_LIQUIDITY_PROVIDERS.md create mode 100644 integrations/maya/NEAR_INTENTS_PROTOCOL.md create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RouteProvider.kt create mode 100644 integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerScreen.kt create mode 100644 integrations/maya/src/main/res/drawable/ic_coin_placeholder.xml delete mode 100644 integrations/maya/src/main/res/layout/fragment_currency_picker.xml create mode 100644 integrations/maya/src/test/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrencyTest.kt create mode 100644 integrations/maya/src/test/java/org/dash/wallet/integrations/maya/payments/parsers/AddressParserTest.kt diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt b/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt new file mode 100644 index 0000000000..bd10e3dd21 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Visual/interaction state of a [CoinSelect] row (DashDEX "Coin select" design system). + */ +enum class CoinSelectState { + /** Selectable. Full-colour icon; shows the trailing price/network block. */ + Active, + + /** Trading is halted for this chain — not selectable. Desaturated, with a "halted" badge. */ + HaltedChain, + + /** Not selectable for another reason. Desaturated, no trailing block. */ + Disabled +} + +/** + * A single selectable coin row used on the DashDEX "Select coin" screen. + * Implements the design-system component (Figma node `7872:1756`) and its four states + * (`7872:1765`): Active (single / multiple network), Halted chain, and Disabled. + * + * Non-active states are rendered exactly as specified by the design: a black saturation + * overlay desaturates the whole row (so the colour logo turns grey), the logo drops to 50% + * opacity, and the name/symbol switch to the tertiary text colour. Halted additionally shows + * a "halted" badge; Disabled shows no trailing content. Only [CoinSelectState.Active] rows + * are clickable. + * + * @param coinIcon slot for the coin logo (e.g. a Coil `AsyncImage`); sized to 30dp. + * @param price trailing price text, shown only in the Active state. + * @param network trailing network label (e.g. "NEAR", "Multiple"), shown only in the Active state. + * @param haltedLabel text of the badge shown in the [CoinSelectState.HaltedChain] state. + */ +@Composable +fun CoinSelect( + name: String, + symbol: String, + modifier: Modifier = Modifier, + coinIcon: @Composable () -> Unit = { CoinSelectPlaceholderIcon() }, + state: CoinSelectState = CoinSelectState.Active, + price: String? = null, + network: String? = null, + haltedLabel: String = "halted", + onClick: (() -> Unit)? = null +) { + val isGreyed = state != CoinSelectState.Active + val nameColor = if (isGreyed) MyTheme.Colors.textTertiary else MyTheme.Colors.textPrimary + val symbolColor = if (isGreyed) MyTheme.Colors.textTertiary else MyTheme.Colors.textSecondary + + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (state == CoinSelectState.Active && onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } + ) + // mix-blend-saturation with a black source desaturates everything below it, + // turning the colour logo grey for the non-selectable states. Offscreen + // compositing isolates the blend to this row's content. + .then(if (isGreyed) Modifier.desaturate() else Modifier) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(30.dp) + .then(if (isGreyed) Modifier.alpha(0.5f) else Modifier), + contentAlignment = Alignment.Center + ) { + coinIcon() + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = name, + style = MyTheme.Typography.BodyMediumMedium, + color = nameColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = symbol, + style = MyTheme.Typography.BodySmall, + color = symbolColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + when (state) { + CoinSelectState.Active -> { + if (price != null || network != null) { + Column(horizontalAlignment = Alignment.End) { + price?.let { + Text( + text = it, + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textPrimary + ) + } + network?.let { + Text( + text = it, + style = MyTheme.Typography.BodySmall, + color = MyTheme.Colors.textSecondary + ) + } + } + } + } + + CoinSelectState.HaltedChain -> CoinSelectBadge(haltedLabel) + + CoinSelectState.Disabled -> Unit + } + } +} + +/** The small pill shown in the halted state (black-8% background, secondary text). */ +@Composable +private fun CoinSelectBadge(label: String) { + Text( + text = label, + style = MyTheme.Typography.BodySmallMedium, + color = MyTheme.Colors.textSecondary, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF0A0B0D).copy(alpha = 0.08f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +/** Neutral 30dp placeholder used when no [coinIcon] is supplied. */ +@Composable +fun CoinSelectPlaceholderIcon() { + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(MyTheme.Colors.lightGray) + ) +} + +/** + * Desaturate this content (the design's black `mix-blend-saturation` overlay). Implemented + * as a saturation-0 colour matrix applied to an offscreen layer, so only drawn pixels (the + * colour logo, text) turn grey — transparent areas stay transparent rather than going black. + */ +private fun Modifier.desaturate(): Modifier = this.drawWithCache { + val paint = Paint().apply { + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + } + onDrawWithContent { + drawIntoCanvas { canvas -> + canvas.saveLayer(Rect(Offset.Zero, size), paint) + drawContent() + canvas.restore() + } + } +} + +// ── Previews ──────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun CoinSelectStatesPreview() { + Column( + modifier = Modifier + .background(MyTheme.Colors.backgroundSecondary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + CoinSelectStateLabel("Active — single network") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Active, + price = "$0.00", + network = "NEAR", + onClick = {} + ) + + CoinSelectStateLabel("Active — multiple networks") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Active, + price = "$0.00", + network = "Multiple", + onClick = {} + ) + + CoinSelectStateLabel("Halted chain") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.HaltedChain, + haltedLabel = "halted" + ) + + CoinSelectStateLabel("Disabled") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Disabled + ) + } +} + +@Composable +private fun CoinSelectStateLabel(text: String) { + Text( + text = text, + style = MyTheme.Typography.BodySmall, + color = MyTheme.Colors.textTertiary, + modifier = Modifier.padding(top = 8.dp, start = 10.dp) + ) +} + +/** Colour preview icon so the desaturation in the non-active states is visible. */ +@Composable +private fun PreviewCoinIcon() { + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color(0xFFF3BA2F)) + ) +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt b/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt new file mode 100644 index 0000000000..db25c5268d --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.dash.wallet.common.R + +// Figma: colors/gray/gray400/gray400alpha10 — the search field background. +private val SearchFieldBackground = Color(0x1A75808A) + +// Figma: colors/gray/black/black1000alpha30 — placeholder text. +private val SearchPlaceholderColor = Color(0x4D0A0B0D) + +/** + * Design-system search field. + * + * Mirrors the "search - states" component in the Android design system + * (Figma node 4249-12620). Two optional affordances: + * - **Clear** ("x") icon inside the field — shown when [showClearButton] is true and + * [query] is non-empty; tapping it clears the text via [onQueryChange]. + * - **Cancel** button to the right of the field — shown only when [onCancel] is non-null + * (typically while the field is focused). + */ +@Composable +fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = stringResource(R.string.search_hint), + showClearButton: Boolean = true, + onCancel: (() -> Unit)? = null, + cancelText: String = stringResource(R.string.button_cancel), + imeAction: ImeAction = ImeAction.Search, + onSearch: (() -> Unit)? = null +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(16.dp)) + .background(SearchFieldBackground), + singleLine = true, + textStyle = MyTheme.Body2Regular.copy(color = MyTheme.Colors.textPrimary), + cursorBrush = SolidColor(MyTheme.Colors.dashBlue), + keyboardOptions = KeyboardOptions(imeAction = imeAction), + keyboardActions = KeyboardActions(onSearch = { onSearch?.invoke() }), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = MyTheme.Colors.textTertiary, + modifier = Modifier.size(20.dp) + ) + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + text = placeholder, + style = MyTheme.Body2Regular, + color = SearchPlaceholderColor + ) + } + innerTextField() + } + if (showClearButton && query.isNotEmpty()) { + Icon( + painter = painterResource(R.drawable.ic_clear_input), + contentDescription = stringResource(R.string.button_clear), + tint = Color.Unspecified, + modifier = Modifier + .size(20.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onQueryChange("") } + ) + } + } + } + ) + + if (onCancel != null) { + Text( + text = cancelText, + style = MyTheme.CaptionMedium, + color = MyTheme.Colors.textPrimary, + modifier = Modifier + .clip(RoundedCornerShape(11.dp)) + .clickable { onCancel() } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +// ── Previews ──────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, widthDp = 393) +@Composable +private fun SearchFieldStatesPreview() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Empty, no Cancel + SearchField(query = "", onQueryChange = {}) + // Empty, with Cancel (focused) + SearchField(query = "", onQueryChange = {}, onCancel = {}) + // Filled, with clear + Cancel + SearchField(query = "some text", onQueryChange = {}, onCancel = {}) + // Filled, no Cancel + SearchField(query = "some text", onQueryChange = {}) + } +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt b/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt index fb58ffeb28..d4a067c118 100644 --- a/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt +++ b/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt @@ -104,9 +104,36 @@ object GenericUtils { return currency.getSymbol(getDeviceLocale()) } - fun getCoinIcon(code: String): String { - return "https://raw.githubusercontent.com/jsupa/crypto-icons/main/icons/" + - code.lowercase(Locale.getDefault()) + ".png" + /** + * Ordered list of candidate icon URLs for a coin, to be tried in sequence until + * one loads. + * + * When a SwapKit [identifier] is supplied (e.g. "ETH.USDC-0x...") the SwapKit + * token-list bucket is tried first: it keys off the full chain-qualified + * identifier, so it disambiguates same-ticker tokens across chains and has the + * widest coverage of the assets the wallet can route. The bucket only serves + * fully-lowercased identifier filenames. CoinCap (broader generic coverage, + * includes Solana memecoins like WIF that the older jsupa repo lacks) and the + * jsupa repo follow as ticker-keyed fallbacks. + * + * Some assets (e.g. Solana tokens like $WIF) carry a leading '$' or other + * non-alphanumeric characters in their symbol; the ticker-keyed hosts key off + * the plain ticker (wif), so strip anything that isn't alphanumeric. + */ + fun getCoinIconUrls(code: String, identifier: String? = null): List { + val sanitized = code.lowercase(Locale.getDefault()).filter { it.isLetterOrDigit() } + val urls = mutableListOf() + if (!identifier.isNullOrEmpty()) { + val swapKitId = identifier.lowercase(Locale.getDefault()) + urls.add("https://storage.googleapis.com/token-list-swapkit/images/$swapKitId.png") + } + urls.add("https://assets.coincap.io/assets/icons/$sanitized@2x.png") + urls.add("https://raw.githubusercontent.com/jsupa/crypto-icons/main/icons/$sanitized.png") + return urls + } + + fun getCoinIcon(code: String, identifier: String? = null): String { + return getCoinIconUrls(code, identifier).first() } /** diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index a8e53246f3..ef09eb36d8 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ Get Quote Close Cancel + Clear Confirm Retry Send Report diff --git a/common/src/test/java/org/dash/wallet/common/payments/parsers/AddressParserTest.kt b/common/src/test/java/org/dash/wallet/common/payments/parsers/AddressParserTest.kt index ad43e515da..eaa08b765b 100644 --- a/common/src/test/java/org/dash/wallet/common/payments/parsers/AddressParserTest.kt +++ b/common/src/test/java/org/dash/wallet/common/payments/parsers/AddressParserTest.kt @@ -54,7 +54,9 @@ class AddressParserTest { assertTrue(parser.exactMatch("34Me5SAG8W8Bf2LxGfPiqVZRKKV1VL1hmW")) assertTrue(parser.exactMatch("bc1qxhgnnp745zryn2ud8hm6k3mygkkpkm35020js0")) assertTrue(parser.exactMatch("bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297")) + assertTrue(parser.exactMatch("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0")) SegwitAddress.fromBech32(network, "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297") + SegwitAddress.fromBech32(network, "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0") assertEquals( 4, diff --git a/integrations/maya/MAYA_LIQUIDITY_PROVIDERS.md b/integrations/maya/MAYA_LIQUIDITY_PROVIDERS.md new file mode 100644 index 0000000000..059a0f4842 --- /dev/null +++ b/integrations/maya/MAYA_LIQUIDITY_PROVIDERS.md @@ -0,0 +1,265 @@ +# Maya Protocol — Liquidity Providers + +This document investigates the **liquidity-provider (LP)** surface of Maya Protocol and how it +maps onto the existing swap integration in the Dash Wallet. It is a feasibility / design +reference, not (yet) an implemented feature. + +See also: [`MAYA_PROTOCOL.md`](./MAYA_PROTOCOL.md) for the swap integration this builds on. + +## Overview + +Providing liquidity reuses the **exact same on-chain mechanism as a swap**: the user sends a +deposit to the chain's inbound vault with an `OP_RETURN` memo telling Maya what to do. The only +thing that changes between a swap and an LP action is the **memo string**. + +Everything already built for swaps therefore applies unchanged: + +- Inbound vault-address fetching (`GET /mayachain/inbound_addresses`) +- `halted` / trading-paused safety checks +- The `VOUT0 vault / VOUT1 OP_RETURN / VOUT2 change` transaction layout in `MayaBlockchainApi` +- The **no-BIP69 output sorting** rule (Maya requires a specific output order) +- The 80-byte `OP_RETURN` memo limit + +## The LP role (how it works conceptually) + +Source: [Maya docs — Liquidity Providers](https://docs.mayaprotocol.com/introduction/readme/roles/liquidity-providers). + +LPs deposit assets into pools and earn yield from swap fees + system rewards, in exchange for +taking on price exposure (and impermanent loss). Key points: + +### Pools always pair against CACAO + +Every pool is `ASSET/CACAO`. An LP in the `DASH/CACAO` pool earns rewards in **both DASH and +CACAO**, and CACAO is held as "a redeemable insurance policy whilst they are in the pool." This +is why a true dual-sided position needs CACAO — the wallet's single-sided constraint stands. + +### Deposit types + +- **Symmetrical** — equal value of both sides (e.g. $1000 DASH + $1000 CACAO). +- **Asymmetrical** — unequal, including **single-sided** (e.g. $2000 DASH + $0 CACAO). The LP + receives pool ownership accounting for the price slip they create. Maya notes there is "no + difference between swapping into symmetrical shares then depositing, or depositing + asymmetrically and being arb'd to symmetrical." Recommended when the pool is already + imbalanced. +- **Multiple deposits** — rules vary by the initial deposit type. Asymmetric deposits with the + *non-deposited* asset generally create a **new** LP position rather than topping up; likewise + a symmetric deposit after a 100%-CACAO deposit creates a new position. (Relevant to our + address-keying problem below — positions are not always additive.) + +### Pool ownership = Liquidity Units + +Depositing mints **Liquidity Units** representing the LP's share of the pool. Value per unit is +tracked by **LUVI** (Liquidity Unit Value Index): + +``` +LUVI = sqrt(AssetDepth × CacaoDepth) / PoolUnits +``` + +APR is derived by extrapolating the change in LUVI over a window (default 30 days). LUVI rises +from swap fees, block rewards, and donations; falls as synth liability grows. (These are the +`LP_units` / `pool_units` already present in `PoolInfo`.) + +### How rewards accrue + +Yield is computed **each block** and paid **on withdrawal**, in both CACAO and the paired asset: + +- **Blocks with swaps** — rewards split proportionally to **fees collected** per pool. +- **Blocks without swaps** — rewards split proportionally to **pool depth**. +- **Incentive Pendulum** — shifts emissions between node operators (bonded capital) and LPs + (pooled capital) to keep the system balanced; affects LP yield over time. + +Reward sources: a portion of each swap **slip** retained in the pool, MAYAChain **block +rewards**, and long-term payouts from the **token reserve**. + +### Deposit / withdrawal rules + +- **No minimum deposit**, but it must cover the transaction + withdrawal fees. +- **Non-custodial** — only the original depositor can withdraw. +- **Withdraw anytime** — the only wait is on-chain confirmation time; no lockup/cooldown. +- A **withdrawal fee** is applied and placed into the network reserve. +- Each `ADD` **resets the Impermanent-Loss-Protection timer** (per the docs). + +### Risks & costs + +- **Impermanent loss** — LPs are not entitled to a fixed quantity back; they get their fair + share of the pool's earnings and *final* balances. IL can exceed earned rewards. +- **Costs** — direct: network withdrawal fee; indirect: IL from price divergence. +- **No misconduct penalties** for LPs. + +### Strategy framing + +- **Passive** — deep-liquidity pools to minimise risk. +- **Active** — shallow but high-demand pools for bigger slips/fees (higher risk). + +## Terminology note (THORChain vs. Maya) + +The official memo docs +([transaction-memos](https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos)) +are written in THORChain terms. For Maya, substitute: + +| THORChain term | Maya equivalent | +|---------------------|---------------------| +| `RUNE` | `CACAO` | +| THORChain address | `MAYA` chain address| +| `THORName` | `MAYAName` | + +Maya's native chain is `MAYA` and its native asset is `CACAO`. + +## LP Transaction Memos + +### ADD LIQUIDITY + +**Format:** `ADD:POOL:PAIREDADDR:AFFILIATE:FEE` +**Abbreviations:** `a`, `+` + +| Field | Required | Notes | +|---------------|----------|-------| +| `POOL` | yes | Target pool, e.g. `DASH.DASH` (may be shortened) | +| `PAIREDADDR` | no | Counterparty address for **two-sided** deposits. On an external chain it links to a `MAYA` address; on `MAYA` it links to the external address. **Omit for single-sided.** | +| `AFFILIATE` | no | `MAYAName` or `MAYA` address | +| `FEE` | no | Affiliate fee, 0–1000 basis points | + +**Examples:** +- `ADD:POOL` — single-sided deposit +- `+:POOL:PAIREDADDR` — both-sided deposit +- `+:POOL:PAIREDADDR:AFFILIATE:FEE` — both-sided with affiliate + +### WITHDRAW LIQUIDITY + +**Format:** `WD:POOL:BASISPOINTS:ASSET` +**Abbreviations:** `-`, `wd`, `WITHDRAW` + +| Field | Required | Notes | +|---------------|----------|-------| +| `POOL` | yes | Source pool (may be shortened) | +| `BASISPOINTS` | yes | 0–10,000, where 10,000 = 100% withdrawal | +| `ASSET` | no | Single-sided withdrawal to the named asset (CACAO or the pool asset). Omit for dual-sided. | + +**Examples:** +- `WITHDRAW:POOL:10000` — dual-sided 100% exit +- `-:POOL:1000` — dual-sided 10% exit +- `wd:DASH.DASH:5000:DASH.DASH` — 50% exit, received as DASH + +A withdraw deposit carries **no value** — it is a dust transaction to the vault that only +delivers the instruction. Maya pays the redeemed amount out from the pool. + +### Related node memos (out of scope) + +These require CACAO and a `MAYA` account, so they are **not feasible for a DASH-only wallet**: + +- **BOND:** `BOND:ASSET:UNITS:NODEADDR:PROVIDER:FEE` +- **UNBOND:** `UNBOND:NODEADDR:AMOUNT` +- **LEAVE:** `LEAVE:NODEADDR` + +## Single-sided vs. dual-sided for this wallet + +- **Single-sided DASH LP** (`ADD:DASH.DASH`) — deposit only DASH. No counterparty address + needed. **This is the only realistic path for the current wallet.** +- **Dual-sided** (`+:DASH.DASH:PAIREDADDR`) — deposit DASH *and* CACAO, where `PAIREDADDR` + links the DASH-side deposit to a `MAYA`-chain address supplying the CACAO half. The wallet + has **no CACAO/MAYA account**, so dual-sided is not feasible without standing up CACAO + custody. +- **Exit** is correspondingly **single-sided to DASH** (`wd:DASH.DASH::DASH.DASH`). + +> Open question: confirm Maya currently permits single-sided LP on the `DASH.DASH` pool. Some +> pools restrict single-sided adds. + +## What already exists (reusable) + +| Capability | Where | +|-------------------------------------|-------| +| Pool data incl. `lpUnits`, `poolUnits`, `balanceCacao`, `balanceAsset`, `bondable` | `model/PoolInfo.kt` | +| Pool fetch + 30s auto-refresh + pricing | `api/MayaApiAggregator.kt`, `api/MayaWebApi.kt` | +| Inbound vault addresses + `halted` checks | `api/MayaWebApi.getInboundAddresses()`, `model/InboundAddress.kt` | +| Build + broadcast vault+memo+change tx | `api/MayaBlockchainApi.kt`, `api/MayaBlockchainApiImpl.kt` | + +An LP deposit is the same `MayaBlockchainApi` call as a swap, with a different memo. + +## LP yield / APY + +**Yes, Maya publishes an LP APY — but only via Midgard, not mayanode.** + +LPs earn from two sources, rolled into a single APY figure: +1. **Swap fees** charged on the pool, and +2. **System income** (CACAO block rewards) allocated to pools by depth. + +### Where the numbers live + +- **mayanode `/pools`** (the source this branch currently uses) exposes **no yield fields** — + only balances, `LP_units`, `pool_units`, `status`, `bondable`, synth accounting. There is no + APY/APR/earnings/volume on mayanode. +- **Midgard v2** exposes the yield data: + - All pools: `GET https://midgard.mayachain.info/v2/pools` + - One pool, windowed: `GET https://midgard.mayachain.info/v2/pool/DASH.DASH?period=30d` + (periods: `1h`, `24h`, `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all`) + +### Relevant Midgard fields + +| Field | Meaning | +|----------------------------------|---------| +| `annualPercentageRate` / `poolAPY` | LP APY (fees + rewards). `poolAPY` duplicates `annualPercentageRate`. Windowed by `?period=`. | +| `earningsAnnualAsPercentOfDepth` | Annualized earnings ÷ pool depth | +| `earnings` | Raw earnings (CACAO base units) over the window | +| `volume24h` | 24h swap volume | +| `saversAPR` | Savers yield (separate product; `0` for DASH today) | +| `assetPriceUSD`, `assetDepth`, `runeDepth`, `liquidityUnits` | depth/price inputs | + +### Live sample (DASH.DASH, captured 2026-06-09) + +``` +annualPercentageRate / poolAPY : 0.1581 (~15.8%, default/all-time window) + same with ?period=30d : 0.0797 (~8.0%, trailing 30 days) +earningsAnnualAsPercentOfDepth : 0.147 (~14.7%) +saversAPR : 0 +``` + +APY moves with the chosen `period` window — quote the window alongside the number. + +### Implication for this wallet + +This branch **removed Midgard** when `PoolInfo` migrated to mayanode (`poolAPY`, `volume24h`, +`assetPriceUSD`, `assetDepth` were dropped — see `MAYA_PROTOCOL.md`). mayanode cannot supply +APY. So surfacing LP yield requires **re-introducing a single Midgard call** (e.g. a +`MidgardEndpoint.getPools()` or per-pool fetch) purely for the yield figures, separate from the +mayanode pool/price path the swap UI uses. + +## What is missing / the real work + +1. **Position tracking** — no query exists for "what LP units do I own." Maya exposes: + - `GET /mayachain/liquidity_provider/{pool}/{address}` — a single provider's position + - `GET /mayachain/liquidity_providers/{pool}` — all providers in a pool + + Neither is wired up. A new endpoint + model is needed to show position, pool share, and + redeemable value. +2. **Withdraw is address-keyed, not UTXO-keyed** — Maya tracks the LP by the **DASH address** + that deposited. With an HD wallet rotating addresses, we must deposit from (and withdraw + to) a known/consistent address, or record which address holds each position. +3. **No CACAO side** — confines us to single-sided DASH add and single-sided DASH exit. +4. **No LP quote endpoint** — swaps use `/quote/swap`; there is no equivalent LP slippage quote + in the code. Units/share would be estimated client-side from pool depths. +5. **UI** — entirely new. The current Maya UI is swap-only. + +## Backend applicability + +LP is a **Maya-native-only** feature. SwapKit is a swap *aggregator* and does not expose LP, so +any LP action must go direct to `mayanode` regardless of the configured swap backend. + +## Scope recommendation + +Target **single-sided DASH LP**: add, withdraw, and a position view. Bonding/nodes and savers +need CACAO and are out of scope for a DASH-only wallet. + +Open questions to resolve before implementation: + +- Is the goal to **earn yield by providing DASH liquidity** (single-sided add/withdraw + + position view), or something broader? +- Confirm Maya allows single-sided LP on the `DASH.DASH` pool today. +- Decide how to pin the deposit/withdraw address so positions remain queryable. + +## References + +- Liquidity Providers role: https://docs.mayaprotocol.com/introduction/readme/roles/liquidity-providers +- Transaction memos: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos +- Querying MAYAChain: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/querying-mayachain +- Swagger API: https://mayanode.mayachain.info/mayachain/doc +- Main docs: https://docs.mayaprotocol.com/ diff --git a/integrations/maya/NEAR_INTENTS_PROTOCOL.md b/integrations/maya/NEAR_INTENTS_PROTOCOL.md new file mode 100644 index 0000000000..764f9ba044 --- /dev/null +++ b/integrations/maya/NEAR_INTENTS_PROTOCOL.md @@ -0,0 +1,418 @@ +v# NEAR Intents (1Click API) Protocol Integration Documentation + +This document describes the **NEAR Intents 1Click API** — a candidate cross-chain swap backend for the Dash Wallet, evaluated alongside the existing Maya integration and the SwapKit aggregator POC (see `MAYA_PROTOCOL.md` and `SWAPKIT_PROTOCOL.md` in this directory). + +> **Status:** In progress. This doc is being filled in one endpoint at a time. Endpoints marked _(TODO)_ have not yet been documented from the source pages. + +## Overview + +**NEAR Intents** is an intent-based cross-chain settlement system. Instead of the user constructing and signing a chain-specific transaction with routing instructions (Maya's OP_RETURN memo) or the wallet decoding a provider-built payload (SwapKit's `tx`), the user expresses an *intent* ("I want X of asset B for my Y of asset A") and a network of solvers competes to fill it. Settlement happens by the user depositing the source asset to a generated **deposit address**; the solver delivers the destination asset to the user's recipient address. + +The **1Click API** is the REST front-end to NEAR Intents — it abstracts the intent/solver machinery behind a simple quote → deposit → track flow. It is the same provider SwapKit surfaces as the `NEAR` route (NEAR Intents), which in live testing was the memo-free, RECOMMENDED/CHEAPEST route for DASH swaps. + +### Why this matters for the Dash Wallet + +| Concern | Maya (current) | SwapKit | NEAR Intents (1Click) | +|---|---|---|---| +| Routing | Single protocol (Mayanode) | Aggregated across 15+ providers | Solver network | +| Tx construction | Client builds DASH tx + OP_RETURN memo | Server returns signable `tx` | **Plain deposit to address — no memo required** | +| Source address requirement | n/a (wallet builds tx) | `sourceAddress` mandatory on `/v3/swap` | Refund address supplied at quote time (TBD — verify) | +| DASH support | First-class chain | Via Maya only | **First-class** (`dash` in supported blockchains) | +| API key | None | `x-api-key` required | JWT (see Authentication) | + +The standout property for the **buy direction** (X → DASH): NEAR Intents routes DASH natively and memo-free, which is exactly the gap that makes Maya unusable for buys. This is why it's worth documenting on its own rather than only through SwapKit's wrapper. + +--- + +## API Endpoints + +### Base URL + +- **API root**: `https://1click.chaindefuser.com` +- **OpenAPI spec**: `https://1click.chaindefuser.com/docs/v0/openapi.yaml` +- **API version**: `0.1.10` (`/v0/` path prefix) + +### Authentication + +Most read endpoints (e.g. `/v0/tokens`) require **no authentication** (`security: []` in the OpenAPI spec). Quote/swap execution accepts **either** an `X-API-Key` header **or** a **JWT Bearer token** (`/v0/quote` documents both). Fee collection uses the JWT flow. + +> _(TODO: document the JWT acquisition flow from `distribution-channels/1click-api/authentication.md` once that page is provided.)_ + +### Endpoint Summary + +| Method | Path | Purpose | Status | +|---|---|---|---| +| GET | `/v0/tokens` | List supported tokens across all chains | ✅ Documented below | +| POST | `/v0/quote` | Request a swap quote (assets, amount, slippage, recipient/refund) | ✅ Documented below | +| POST | `/v0/deposit/submit` | Notify the service a deposit tx was sent (by hash) | ✅ Documented below | +| GET | `/v0/status` | Check swap execution status by deposit address + memo | ✅ Documented below | +| GET | `/v0/any-input/withdrawals` | List withdrawals for an ANY_INPUT quote (filter/paginate/sort) | ✅ Documented below | +| GET | `/v0/transactions` | Retrieve transaction data | _(TODO)_ | + +> Exact paths for the TODO rows are placeholders inferred from the docs index (`llms.txt`) and will be corrected against each source page as it's added. + +--- + +### 1. Get Supported Tokens + +**Endpoint**: `GET https://1click.chaindefuser.com/v0/tokens` + +Lists every token the 1Click API can swap, across all supported chains. Use to populate source/destination asset pickers and to map a chain + symbol to the `assetId` that quote calls require. Also carries live USD price, so it can double as a price source (like SwapKit's `/price`). + +**Authentication**: None. + +**Request parameters**: None (no query, path, or body). + +**Response**: `200 OK`, `application/json` — an **array** of `TokenResponse` objects. + +**`TokenResponse` object**: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `assetId` | string | yes | **Primary key** for quote calls. NEAR-native format, e.g. `nep141:wrap.near`. This is *not* the `CHAIN.ASSET` notation Maya/SwapKit use — it's the NEAR Intents asset identifier. | +| `decimals` | number | yes | Token precision (e.g. `24` for wNEAR). Authoritative for base-unit conversion. | +| `blockchain` | string (enum) | yes | Chain the token lives on (see enum below). | +| `symbol` | string | yes | Display symbol (e.g. `BTC`, `ETH`, `wNEAR`). | +| `price` | number | yes | Current USD price (e.g. `2.79`). | +| `priceUpdatedAt` | string (ISO 8601 date-time) | yes | When the price was last refreshed. | +| `contractAddress` | string | no | Token contract address (omitted for gas/native tokens), e.g. `wrap.near`. | + +**Supported `blockchain` enum values**: + +``` +near, eth, base, arb, btc, sol, ton, dash, doge, xrp, zec, gnosis, bera, +bsc, pol, tron, sui, op, avax, cardano, ltc, xlayer, monad, bch, adi, +plasma, scroll, starknet, aleo +``` + +> **For the Dash Wallet**: `dash` is a first-class blockchain value — DASH is supported natively, not only as a Maya-routed asset. Find the DASH entry (filter `blockchain == "dash"`) to obtain its `assetId` for quote requests, and read `decimals` from there rather than hardcoding 8. + +**Notes**: + +- No rate limits documented. +- The asset identifier scheme (`nep141:…`, etc.) differs from Maya/SwapKit's `CHAIN.ASSET[-CONTRACT]`. Any shared model code with the Maya module must translate at the boundary — **do not** assume `DASH.DASH`-style strings. + +--- + +### 2. Request a Swap Quote — `/v0/quote` + +**Endpoint**: `POST https://1click.chaindefuser.com/v0/quote` + +Step 1 of the swap flow. Returns pricing **and** the generated `depositAddress` in a single call (unlike SwapKit's two-step quote→swap). The response is signed by the service. + +**Authentication**: `X-API-Key` header **or** JWT Bearer token. + +**Request body — required fields**: + +| Field | Type | Notes | +|---|---|---| +| `dry` | boolean | `true` = price-only dry run (no deposit address committed). Use for indicative quotes; set `false` to get a live deposit address. | +| `swapType` | string (enum) | `EXACT_INPUT` \| `EXACT_OUTPUT` \| `FLEX_INPUT` \| `ANY_INPUT` — how `amount` is interpreted (see below). | +| `slippageTolerance` | number | **Basis points** (100 = 1%), not percent. Contrast SwapKit's `slippage` which was whole-percent. | +| `originAsset` | string | Source `assetId` from `/v0/tokens` (e.g. `nep141:…`). | +| `destinationAsset` | string | Destination `assetId`. | +| `amount` | string | Base amount in **smallest unit, integer only, no decimals** — opposite of SwapKit/Maya which take human decimals. Use the token's `decimals` from `/v0/tokens` to scale. | +| `depositType` | string (enum) | `ORIGIN_CHAIN` \| `INTENTS` \| `CONFIDENTIAL_INTENTS` — where the deposit comes from. For an external-chain deposit (our buy case), `ORIGIN_CHAIN`. | +| `recipient` | string | Destination address (the user's DASH receive address for buys). | +| `recipientType` | string (enum) | `DESTINATION_CHAIN` \| `INTENTS` \| `CONFIDENTIAL_INTENTS`. For delivery to a normal DASH address, `DESTINATION_CHAIN`. | +| `refundTo` | string | **Refund address** — supplied at quote time. This is the recurring concern for buys (see notes). | +| `refundType` | string (enum) | `ORIGIN_CHAIN` \| `INTENTS` \| `CONFIDENTIAL_INTENTS`. | +| `deadline` | string (ISO 8601) | When the deposit address becomes inactive; must be far enough out to cover mining time. | + +**Request body — optional fields**: + +| Field | Type | Notes | +|---|---|---| +| `depositMode` | string (enum) | `SIMPLE` (default) \| `MEMO`. **`SIMPLE` = plain transfer, no memo** — this is the memo-free deposit that makes the buy direction viable. | +| `refundFee` | string | Refund fee in smallest unit. | +| `connectedWallets` | array | Connected wallet addresses. | +| `sessionId` | string | Client session id. | +| `virtualChainRecipient` / `virtualChainRefundRecipient` | string | EVM addresses for virtual-chain routing (not relevant to DASH). | +| `customRecipientMsg` | string | Message for `ft_transfer_call` (experimental). | +| `confidentiality` | string (enum) | `public` (default) \| `basic` \| `advanced`. | +| `referral` | string | Distribution-channel identifier (lowercase) — the Dash affiliate handle. | +| `rebates` | array | Up to 3 rebate recipients with share percentages. | +| `quoteWaitingTimeMs` | number | Ms to wait for a relay quote (default 0). | +| `appFees` | array | Recipient fee objects — where the Dash app fee is configured per-request. | + +**Response — top level**: + +| Field | Type | Notes | +|---|---|---| +| `correlationId` | string | Request-tracing id. | +| `timestamp` | string (ISO 8601) | Timestamp used to derive the deposit address. | +| `signature` | string | Service signature attesting the quote. | +| `quoteRequest` | object | Echo of the submitted request. | +| `quote` | object | Pricing + deposit details (below). | + +**Response — `quote` object**: + +| Field | Type | Notes | +|---|---|---| +| `depositAddress` | string | Address on the origin chain (or Intents verifier contract) the user deposits into. **For DASH-as-destination buys, this is the address on the *source* chain.** | +| `depositMemo` | string | Memo, only when `depositMode: MEMO` was requested or the chain requires it. Absent for `SIMPLE`. | +| `amountIn` / `amountInFormatted` / `amountInUsd` | string | Input amount (smallest unit / human / USD). | +| `minAmountIn` | string | Minimum input for execution. | +| `amountOut` / `amountOutFormatted` / `amountOutUsd` | string | **Expected output** (smallest unit / human / USD) — for buys, the DASH the user receives. | +| `minAmountOut` | string | Output floor after slippage — the guaranteed minimum. | +| `deadline` | string (ISO 8601) | When the deposit address goes inactive. | +| `timeWhenInactive` | string (ISO 8601) | When the address goes "cold" (still works, slower processing). | +| `timeEstimate` | number | Seconds to execute after the deposit confirms. | +| `refundFee` | string | Refund fee in smallest unit. | +| `virtualChainRecipient` / `virtualChainRefundRecipient` / `customRecipientMsg` | string | Echoed virtual-chain / experimental fields. | + +**`swapType` semantics** (directly relevant to the buy-flow "user sent the wrong amount" problem): + +- **`EXACT_INPUT`** — fixed input, variable output; **excess deposit is refunded**. +- **`EXACT_OUTPUT`** — fixed output, input adjusted within slippage; **surplus refunded**. +- **`FLEX_INPUT`** — partial deposits accepted; valid range bounded by slippage on both sides. +- **`ANY_INPUT`** — accepts whatever is deposited (see the ANY_INPUT withdrawals endpoint). This is the mode that removes the fixed-amount-deposit failure mode: the user can send an arbitrary amount and it's swapped at execution. + +> **Buy-flow implication**: `ANY_INPUT` (or `FLEX_INPUT`) is the answer to a long-standing concern — with a human paying from an external wallet, the deposited amount rarely matches a quote to the satoshi. These modes let the swap proceed (or refund the excess) instead of failing outright. + +--- + +### 3. Submit Deposit Transaction Hash — `/v0/deposit/submit` + +**Endpoint**: `POST https://1click.chaindefuser.com/v0/deposit/submit` + +**Optional but recommended.** Tells the service the deposit tx hash so it can proactively verify and start processing instead of waiting to discover the deposit by polling the chain. Omitting it does **not** block the swap — it just makes detection slower. + +**Authentication**: `X-API-Key` (recommended) **or** JWT Bearer (legacy). + +**Request body**: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `txHash` | string | **yes** | Hash of the deposit transaction. | +| `depositAddress` | string | **yes** | The quote's `depositAddress`. | +| `nearSenderAccount` | string | no | Sender account when the deposit originates on NEAR (e.g. `relay.tg`). Not relevant for an external-chain (e.g. BTC) deposit. | +| `memo` | string | no | Deposit memo, only if one was issued (`depositMode: MEMO`). | + +**Response**: **identical shape to `/v0/status`** — `{ correlationId, status, updatedAt, quoteResponse, swapDetails }` with the same `status` enum and `swapDetails`/`TransactionDetails` sub-objects (see §4). In effect this is "submit the hash *and* get the current status back in one call." + +**Errors**: + +| HTTP | Meaning | +|---|---| +| `400` | `{ "message": "…" }` — malformed `txHash`/`depositAddress` or missing required field. | +| `401` | Invalid API key / JWT. | +| `404` | Not in the spec, but possible if the deposit/correlation id isn't found. | + +> **Where this fits the buy flow**: For a buy paid from an *external* wallet, the wallet usually won't have the user's source-chain `txHash` — so this call is typically **skipped** for buys, and we rely on `/v0/status` polling (which discovers `originChainTxHashes` on its own). It's only useful when the wallet itself broadcast the deposit (i.e. the **sell** direction, DASH → X) and therefore knows the hash — there it's worth calling to cut detection latency. + +--- + +### 4. Check Swap Execution Status — `/v0/status` + +**Endpoint**: `GET https://1click.chaindefuser.com/v0/status` + +Poll this with the `depositAddress` from the quote to track a swap end-to-end. **Tracking works by deposit address alone** (plus memo only when one was issued) — the same convenient property SwapKit's `/track` had, and exactly what the buy flow needs since the wallet knows the deposit address but not the user's source-chain tx hash. + +**Authentication**: `X-API-Key` (recommended) **or** JWT Bearer (legacy). + +**Request — query parameters**: + +| Parameter | Type | Required | Notes | +|---|---|---|---| +| `depositAddress` | string | **yes** | The `quote.depositAddress` returned by `/v0/quote`. | +| `depositMemo` | string | no | Required **only** if the quote issued a `depositMemo` (i.e. `depositMode: MEMO`). For `SIMPLE` deposits, omit. | + +**Response — top level**: + +| Field | Type | Notes | +|---|---|---| +| `correlationId` | string (UUID) | Request-tracing id. | +| `status` | string (enum) | Lifecycle state (below). | +| `updatedAt` | string (ISO 8601) | Last state change. | +| `quoteResponse` | object | Full echo of the original `/v0/quote` response, including the `signature` — **retain client-side for dispute resolution**. | +| `swapDetails` | object | Execution detail (below). | + +**`status` enum**: + +| Value | Meaning | +|---|---| +| `PENDING_DEPOSIT` | Waiting for the user's deposit to arrive. | +| `KNOWN_DEPOSIT_TX` | Deposit tx seen (e.g. via `/v0/deposit/submit`) but not yet confirmed/processed. | +| `INCOMPLETE_DEPOSIT` | A deposit arrived but is short of the required amount (relevant to `FLEX_INPUT`/partial). | +| `PROCESSING` | Deposit accepted; solver executing the swap. | +| `SUCCESS` | Destination asset delivered to `recipient`. | +| `REFUNDED` | Funds returned to `refundTo` (see `refundReason`). | +| `FAILED` | Swap failed. | + +> No `EXPIRED` state is listed — an unfunded address presumably stays `PENDING_DEPOSIT` past `deadline`. Confirm behavior of a deposit that lands *after* `deadline`/`timeWhenInactive`. + +**`swapDetails` object**: + +| Field | Type | Req | Notes | +|---|---|---|---| +| `intentHashes` | string[] | yes | All NEAR-Intents intent hashes for this swap. | +| `nearTxHashes` | string[] | yes | NEAR transactions executed. | +| `amountIn` / `amountInFormatted` / `amountInUsd` | string | yes | Exact input (smallest unit / human / USD). | +| `amountOut` / `amountOutFormatted` / `amountOutUsd` | string | yes | Exact output delivered — for buys, the DASH received. | +| `slippage` | number | yes | **Actual** slippage % realized (vs. the requested tolerance). | +| `originChainTxHashes` | `TransactionDetails[]` | yes | Source-chain tx hash(es) + explorer URL — i.e. **the user's deposit tx, discovered for us**. | +| `destinationChainTxHashes` | `TransactionDetails[]` | yes | Destination-chain (DASH) delivery tx + explorer URL. | +| `depositedAmount` / `…Formatted` / `…Usd` | string | no | What actually landed on-chain (may differ from quote). | +| `refundedAmount` / `…Formatted` / `…Usd` | string | no | Amount returned to `refundTo`. | +| `refundReason` | string | no | e.g. `"PARTIAL_DEPOSIT"`. | +| `referral` | string | no | Referral identifier echoed back. | + +**`TransactionDetails` sub-object**: `{ hash: string, explorerUrl: string }` — both required. The `explorerUrl` is ready-made for a "view on explorer" deep link, no per-chain URL templating needed. + +**Errors**: + +| HTTP | Meaning | +|---|---| +| `401` | Invalid API key / JWT. | +| `404` | `{ "message": "…" }` — deposit address does not exist. | + +> **Buy-flow win**: `originChainTxHashes` means the wallet learns the user's source-chain deposit tx *without the user pasting it* — solving the awkward tracking gap noted for SwapKit. Poll `/v0/status` by deposit address; surface `SUCCESS` (with `destinationChainTxHashes` for the incoming DASH) or `REFUNDED` (with `refundReason` + `refundedAmount`). + +--- + +### 5. Get ANY_INPUT Withdrawals — `/v0/any-input/withdrawals` + +**Endpoint**: `GET https://1click.chaindefuser.com/v0/any-input/withdrawals` + +Companion to `/v0/status` **specifically for `ANY_INPUT` quotes**. Because an `ANY_INPUT` deposit address accepts *any* amount and can be funded **multiple times**, a single swap state (as `/v0/status` returns) isn't enough — each deposit produces its own withdrawal to the recipient. This endpoint lists those individual withdrawals, with filtering/pagination/sorting. + +> **When you need it**: only if the wallet uses `swapType: ANY_INPUT`. For `EXACT_INPUT` buys (one deposit, one payout) `/v0/status` is sufficient. `ANY_INPUT` is attractive for a "reusable DASH top-up address" UX (send any coin, any number of times, receive DASH each time) — this endpoint is how you'd reconcile those payouts. + +**Authentication**: `X-API-Key` (recommended) **or** JWT Bearer (legacy). + +**Request — query parameters**: + +| Parameter | Type | Required | Notes | +|---|---|---|---| +| `depositAddress` | string | **yes** | The `ANY_INPUT` quote's deposit address to list withdrawals for. | +| `depositMemo` | string | no | Memo, if the deposit address required one. | +| `timestampFrom` | string (ISO 8601) | no | Only withdrawals at/after this time. | +| `page` | number | no | Page number, default `1`. | +| `limit` | number | no | Per page, **default 50, max 50**. | +| `sortOrder` | string (enum) | no | `asc` \| `desc`. | + +**Response — root object**: + +| Field | Type | Notes | +|---|---|---| +| `asset` | string | Destination `assetId` being delivered (for buys, DASH's id). | +| `recipient` | string | Recipient wallet address. | +| `affiliateRecipient` | string | Affiliate recipient address. | +| `withdrawals` | `AnyInputQuoteWithdrawal[]` | The individual payouts (below). | + +> The docs describe `withdrawals` as a single object but it is the list of payouts; treat it as an array. Pagination metadata fields (total/totalPages) are not documented explicitly — paginate via `page`/`limit` and stop when a short page returns. _(Confirm against a live response.)_ + +**`AnyInputQuoteWithdrawal` object**: + +| Field | Type | Notes | +|---|---|---| +| `status` | string (enum) | `SUCCESS` \| `FAILED` (per-withdrawal, narrower than `/v0/status`'s lifecycle enum). | +| `amountOut` / `amountOutFormatted` / `amountOutUsd` | string | Payout amount (smallest unit / human / USD). | +| `withdrawFee` / `withdrawFeeFormatted` / `withdrawFeeUsd` | string | Per-withdrawal fee (smallest unit / human / USD). | +| `timestamp` | string (ISO 8601) | When the withdrawal occurred. | +| `hash` | string | Destination-chain tx hash of the payout. | + +**Errors**: + +| HTTP | Meaning | +|---|---| +| `401` | Invalid API key / JWT. | +| `404` | Deposit address not found (`BadRequestResponse` with `message`). | + +--- + +## Detecting Unavailable / Offline / Halted Assets + +> Findings below were verified live against the public API on 2026-06-08 (no auth required for `/v0/tokens` or `dry` quotes). + +Unlike SwapKit — where a halt, a no-liquidity condition, and an amount-too-small all collapse into the same generic `noRoutesFound` (see `SWAPKIT_PROTOCOL.md` → "how can we tell if trading is halted") — 1Click lets you **distinguish unsupported from temporarily-unavailable from out-of-range**, via two layers. + +### Layer 1 — Permanently unsupported (static) + +`/v0/tokens` carries **no** `enabled` / `available` / `halted` flag — only static metadata. The single static signal is **presence in the list**: + +- Build the asset picker from `/v0/tokens`; an asset absent from the list is unsupported, full stop. +- If a quote is attempted with an unlisted `assetId`, the quote rejects with a specific message: + +| Condition | HTTP | `message` | +|---|---|---| +| Origin asset not in token list | 400 | `tokenIn is not valid` | +| Destination asset not in token list | 400 | `tokenOut is not valid` | + +### Layer 2 — Temporarily unavailable / offline (dynamic) + +An asset can be listed in `/v0/tokens` yet have **no solver/liquidity to fill the swap right now**. Probe this with a **`dry: true` quote** (free, no deposit address committed, no auth). The `message` on a `400` disambiguates the cause: + +| Condition | HTTP | `message` | Interpretation | +|---|---|---|---| +| Healthy pair | 200 | _(returns `quote` with `amountOut`)_ | Swappable now | +| No solver/liquidity for the pair (or amount too large to fill) | 400 | `No liquidity available` | **The closest thing to "offline / halted"** — asset is supported but not fillable right now | +| Amount below the bridge minimum | 400 | `Amount is too low for bridge, try at least ` | **Not** unavailable — just under-minimum; the message gives the exact threshold so the UI can guide the user | +| Amount far below minimum (dust) | 400 | `Failed to get quote` | Effectively under-minimum / unquotable | +| Recipient malformed for the destination chain | 400 | `recipient is not valid` | Caller error, not availability | + +**So `dry: true` is the live availability probe.** A `200` means swappable this moment; a `400` tells you *why not* in an actionable way: +- `No liquidity available` → treat as "temporarily offline" for that asset/pair (banner: "Swaps for X are temporarily unavailable"). This is the signal SwapKit could not give us. +- `Amount is too low…` → do **not** mark the asset unavailable; show the minimum and let the user raise the amount. + +### Caveats + +1. **Errors are free-text `message` strings, not codes.** The 400 body is just `{ "message": "..." }` — there is no machine-readable error-code field. Substring matching (`"No liquidity"`, `"too low"`, `"not valid"`) is fragile to wording changes: always keep a generic fallback, and re-confirm the strings against the OpenAPI spec before hardcoding. +2. **Availability is per-`assetId`, not per-coin.** A coin may have multiple representations (e.g. BTC as both `nep141:btc.omft.near` and `1cs_v1:btc:native:coin`); one can have liquidity while the other does not. Probe the specific `assetId` you intend to use (prefer the canonical `nep141:…omft.near` family — that is what quoted successfully for DASH↔BTC). +3. **Under-minimum ≠ unavailable.** DASH has a real bridge minimum (observed ≈ `14574142` base units ≈ 0.146 DASH in a DASH→BTC test); small swaps are rejected as under-minimum, not as offline — surface them differently. +4. **No bulk status endpoint and no global "all trading halted" flag** (verified against the OpenAPI spec, 2026-06-08). The full spec exposes only `/v0/auth/*`, `/v0/account/balances`, `/v0/tokens`, `/v0/quote`, `/v0/status`, `/v0/any-input/withdrawals`, `/v0/deposit/submit` — there is **no** `/v0/chains` / `/v0/health` / status-all endpoint, the spec contains zero `halt`/`paused`/`health`/`disabled` terms, and `/v0/tokens` carries no availability flag (and ignores `?available=…`). This is the key difference from Maya, which returns per-chain `halted` plus a global `HALTTRADING` in a single `inbound_addresses`/`mimir` call (see `SWAPKIT_PROTOCOL.md`). On 1Click, "which coins are offline right now?" can only be answered by **fanning out one `dry` quote per asset** (O(N) calls) and treating `No liquidity available` as offline — there is no O(1) bulk query. Mitigation: cache `/v0/tokens` for the static list and probe assets lazily on selection, or run a periodic background sweep if a full availability map is ever needed. + +--- + +## Asset Notation + +Unlike Maya/SwapKit (`CHAIN.ASSET[-CONTRACT]`), 1Click uses **NEAR Intents asset identifiers**: + +- `nep141:wrap.near` (wNEAR) +- _(further examples to be added as observed from `/v0/tokens` and quote docs)_ + +The `assetId` returned by `/v0/tokens` is the canonical key for quote calls — never construct it by hand from a symbol. A chain + symbol → `assetId` lookup table built from `/v0/tokens` is the safe approach. + +--- + +## Implementation Notes (for the Dash Wallet) + +- **DASH is native.** No Maya dependency for routing means the global-Maya-halt blind spot documented in `SWAPKIT_PROTOCOL.md` may not apply the same way here — though NEAR Intents' own solver availability becomes the new "is it up?" question (to be investigated once status/quote endpoints are documented). +- **Memo-free deposits** make the buy direction (X → DASH) viable from an external wallet — the core blocker for Maya buys. +- **Asset-ID translation** is the main integration friction: the existing Maya/SwapKit DTOs assume `CHAIN.ASSET` strings; 1Click uses `nep141:…`-style IDs and per-token `decimals`. +- **Refund address handling** (the recurring theme for buys) needs confirmation from the quote endpoint — whether it's supplied at quote time and how ownership/validation is treated. + +--- + +## Open Questions + +1. **Refund address** — _Partially answered._ Supplied at quote time via `refundTo` + `refundType` (required). Still to confirm: validation/ownership rules, and refund behavior under each `swapType`. Note this is *cleaner than SwapKit*: refund is an explicit first-class field, and `EXACT_INPUT`/`EXACT_OUTPUT` explicitly refund excess/surplus. +2. **Quote / deposit-address lifetime** — _Answered._ Controlled by the client via the `deadline` request field; the response also returns `timeWhenInactive` (address goes "cold"/slower) distinct from `deadline` (fully inactive). We choose the window, within service limits (TBD). +3. **JWT auth** — where does the token / API key live (in-app, proxied, remote config)? Same key-management concern as Uphold/Coinbase/SwapKit. 1Click accepts either `X-API-Key` or JWT. +4. **Fees** — _Partially answered._ Per-request via `appFees[]` (recipient fee objects) and `referral` (distribution channel); `rebates[]` allows up to 3 split recipients. Still to document: exact `appFees` object shape and any service/solver fee taken implicitly. (See `fee-config.md`.) +5. **`ANY_INPUT` mode** — _Answered._ Yes. `ANY_INPUT` accepts any deposited amount; `FLEX_INPUT` accepts partials within slippage; `EXACT_INPUT` refunds the excess. Any of these defuses the "user sent a different amount than quoted" failure mode — a major advantage over fixed-amount deposit flows for the buy direction. `ANY_INPUT` addresses are also **reusable / multi-deposit**, with each payout reconciled via `/v0/any-input/withdrawals` (enables a "reusable DASH top-up address" UX). Trade-off: `/v0/status` alone no longer captures the full picture for `ANY_INPUT` — the wallet must also poll the withdrawals endpoint. +6. **Status semantics** — _Answered._ `/v0/status` tracks **by deposit address alone** (memo only if one was issued). Enum: `PENDING_DEPOSIT` → `KNOWN_DEPOSIT_TX` → `PROCESSING` → `SUCCESS` / `REFUNDED` / `FAILED`, plus `INCOMPLETE_DEPOSIT` for shorts. Refunds carry `refundReason` (e.g. `PARTIAL_DEPOSIT`) and `refundedAmount`. The response also surfaces the user's deposit tx (`originChainTxHashes`) and the DASH delivery tx (`destinationChainTxHashes`), each with a ready `explorerUrl`. Still to confirm: behavior of a deposit landing after `deadline`/`timeWhenInactive` (no explicit `EXPIRED` state). +7. **`dry` quotes** — _Answered._ `dry: true` returns a full `quote` with `amountOut`/`minAmountOut` and **no** deposit address committed; no auth required. Safe to call on amount/asset change for live UI preview. +8. **Detecting offline/halted assets** — _Answered._ See "Detecting Unavailable / Offline / Halted Assets" above. No availability flag on `/v0/tokens`; use list-membership (static) + a `dry` quote whose `400` message (`No liquidity available` vs `Amount is too low…` vs `tokenIn/Out is not valid`) classifies the cause. No protocol-wide halt endpoint exists. + +--- + +## Official Documentation + +- **Get Supported Tokens**: https://docs.near-intents.org/api-reference/oneclick/get-supported-tokens +- **Request a Swap Quote**: https://docs.near-intents.org/api-reference/oneclick/request-a-swap-quote +- **Check Swap Execution Status**: https://docs.near-intents.org/api-reference/oneclick/check-swap-execution-status +- **Submit Deposit Transaction Hash**: https://docs.near-intents.org/api-reference/oneclick/submit-deposit-transaction-hash +- **Get ANY_INPUT Withdrawals**: https://docs.near-intents.org/api-reference/oneclick/get-any_input-withdrawals +- **1Click API Overview**: https://docs.near-intents.org/distribution-channels/1click-api/about-1click-api +- **Quickstart**: https://docs.near-intents.org/distribution-channels/1click-api/quickstart +- **Authentication**: https://docs.near-intents.org/distribution-channels/1click-api/authentication +- **Fee Configuration**: https://docs.near-intents.org/distribution-channels/1click-api/fee-config +- **Swap SDK**: https://docs.near-intents.org/distribution-channels/1click-api/sdk +- **OpenAPI Spec**: https://1click.chaindefuser.com/docs/v0/openapi.yaml + +## References + +- NEAR Intents docs: https://docs.near-intents.org/ +- Surfaced via SwapKit as the `NEAR` provider — see `SWAPKIT_PROTOCOL.md`. diff --git a/integrations/maya/SWAPKIT_PROTOCOL.md b/integrations/maya/SWAPKIT_PROTOCOL.md index e15b5b2318..b716005aa7 100644 --- a/integrations/maya/SWAPKIT_PROTOCOL.md +++ b/integrations/maya/SWAPKIT_PROTOCOL.md @@ -448,6 +448,63 @@ Output amounts shown are already net of all fees except inbound. --- +## Detecting Maya-only Assets (for the cryptocurrency list screen) + +> Verified live against the API on 2026-06-08. + +Some coins are routable from DASH **only via MAYACHAIN** — no other provider can carry them. These inherit Maya's two liabilities: (a) halt exposure with **no fallback** (when Maya halts, the coin is simply unavailable — see "how to tell if trading is halted" below), and (b) the OP_RETURN-memo requirement that makes Maya unusable for the **buy** direction from an external wallet. The list screen may want to flag or hide these. + +### Why it's tractable + +Only **two** SwapKit providers route DASH at all — confirmed from `/providers` (the only entries whose `supportedChainIds` contain `dash`): + +- `NEAR` (NEAR Intents) +- `MAYACHAIN_STREAMING` + +(`MAYACHAIN` non-streaming returns `noTokenListsFound` — only the streaming variant carries a token list.) Because DASH originates through exactly these two, **"Maya-only" reduces to "NEAR can't route it."** + +### Method — provider token-list intersection (3 static, cacheable calls) + +Do **not** infer this from a normal quote's `noRoutesFound` — that error is ambiguous (halt vs. no liquidity vs. amount-too-small). Membership in provider token lists is a clean *capability* signal, independent of live liquidity or halt state. + +1. `GET /tokens?provider=NEAR` → set of identifiers NEAR can route. +2. `GET /tokens?provider=MAYACHAIN_STREAMING` → Maya's set. +3. `GET /swapTo?sellAsset=DASH.DASH` → everything reachable from DASH. + +Then classify each reachable identifier: + +``` +maya_only = reachable AND (id ∈ MAYA list) AND (id ∉ NEAR list) +``` + +Compare identifiers case-insensitively; the `identifier` from `/tokens` is canonical (never hand-build from a ticker). + +### Verified result (2026-06-08) + +Counts: NEAR 140 tokens, MAYACHAIN_STREAMING 19, `/swapTo` from DASH = 150 reachable. **11 coins were Maya-only:** + +``` +MAYA.CACAO, MAYA.MAYA, THOR.RUNE, +ETH.MOCA, ETH.WSTETH, +ARB.GLD, ARB.LEO, ARB.USDT, ARB.WBTC, ARB.WSTETH, ARB.YUM +``` + +The other ~139 reachable coins (BTC, ETH, major tokens) are NEAR-capable and therefore survive a Maya halt. + +**Cross-checked with provider-forced quotes** (`providers: [...]` on `/v3/quote`), and the prediction held exactly: + +- `MAYA.CACAO`, `THOR.RUNE` forced to `["NEAR"]` → `noRoutesFound`; forced to `["MAYACHAIN_STREAMING"]` → route returned. ✅ Maya-only. +- `BTC.BTC` → both providers returned routes. ✅ not Maya-only. + +### Caveats + +1. **Capability, not live status.** "Maya-only" means *only Maya can ever route it* — it does not mean Maya is up right now. Combine with halt detection: a Maya-only coin **during** a Maya halt is unavailable with no fallback, exactly the set to grey out / flag first. +2. **Lists drift** — provider token lists change as listings come and go. Refresh on a cadence; do **not** hardcode the 11. +3. **Single-leg assumption.** This treats reachability as "both assets on the same provider." SwapKit can in principle multi-leg, but since DASH originates only via NEAR or Maya and the Maya-only coins returned `noRoutesFound` when NEAR was forced, there is no NEAR→…→coin path in practice today. If SwapKit ever adds DASH to a third provider, re-derive the DASH-provider set from `/providers` first. +4. **Buy direction.** Maya-only coins are precisely the ones unusable as a *buy* source through an external wallet (OP_RETURN memo). Hiding the Maya-only set on the buy screen specifically is a reasonable use of this classification. + +--- + ## Testing Endpoints ```bash diff --git a/integrations/maya/build.gradle b/integrations/maya/build.gradle index afd9789185..6bf51c7bea 100644 --- a/integrations/maya/build.gradle +++ b/integrations/maya/build.gradle @@ -90,6 +90,7 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:$constrainLayoutVersion" implementation "androidx.swiperefreshlayout:swiperefreshlayout:$swipeRefreshLayoutVersion" implementation "io.coil-kt:coil:$coilVersion" + implementation "io.coil-kt:coil-compose:$coilVersion" implementation "androidx.browser:browser:$browserVersion" // Compose diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt index 8db37cd46a..34e48f2001 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/DispatchingSwapProvider.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.bitcoinj.utils.Fiat import org.dash.wallet.common.data.ResponseResource import org.dash.wallet.integrations.maya.model.AccountDataUIModel @@ -65,15 +64,20 @@ class DispatchingSwapProvider @Inject constructor( } @Volatile - private var activeBackend: SwapBackend = readPersistedBlocking() + private var activeBackend: SwapBackend = SwapBackend.MAYA private val persistScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private fun readPersistedBlocking(): SwapBackend { - val configured = runBlocking { config.get(MayaConfig.SWAP_BACKEND) } - ?.let { runCatching { SwapBackend.valueOf(it) }.getOrNull() } - ?: SwapBackend.MAYA - return effective(configured) + init { + // Load the persisted backend off the main thread to avoid blocking the + // constructing thread (Hilt may build this singleton on the main thread). + persistScope.launch { + val configured = runCatching { config.get(MayaConfig.SWAP_BACKEND) } + .getOrNull() + ?.let { runCatching { SwapBackend.valueOf(it) }.getOrNull() } + ?: SwapBackend.MAYA + activeBackend = effective(configured) + } } private fun effective(requested: SwapBackend): SwapBackend { @@ -110,6 +114,9 @@ class DispatchingSwapProvider @Inject constructor( override val apiError: StateFlow get() = active.apiError + override val preferredRouteProviders: StateFlow> + get() = active.preferredRouteProviders + override var notificationIntent: Intent? get() = active.notificationIntent set(value) { active.notificationIntent = value } @@ -149,4 +156,4 @@ class DispatchingSwapProvider @Inject constructor( override fun applyPoolPrices(pools: List, usdToFiat: Fiat) { active.applyPoolPrices(pools, usdToFiat) } -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt index 5000306dbf..6aadcca601 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaApi.kt @@ -70,10 +70,11 @@ interface MayaApi { fun observePoolList(fiatExchangeRate: Fiat): Flow> suspend fun getInboundAddresses(): List + // Default lives on [SwapProvider.getDefaultSwapQuote] — Kotlin refuses defaults // declared on more than one super interface, so [MayaApiAggregator] gets the // default solely from [SwapProvider]. - suspend fun getDefaultSwapQuote(toAsset: String, value: Long = 1_0000_0000): SwapQuote? + suspend fun getDefaultSwapQuote(toAsset: String, value: Long): SwapQuote? } class MayaApiAggregator @Inject constructor( @@ -233,7 +234,7 @@ class MayaApiAggregator @Inject constructor( // Sum of asset balances / sum of cacao balances naturally weights by depth. val stablePools = pools.filter { (it.currencyCode == "USDT" || it.currencyCode == "USDC") && - it.status.equals("available", ignoreCase = true) + it.status.equals("available", ignoreCase = true) } val sumStableCacao = stablePools.fold(BigDecimal.ZERO) { acc, p -> acc + (p.balanceCacao.toBigDecimalOrNull() ?: BigDecimal.ZERO) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt index f1c175c908..5534c918f9 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt @@ -63,6 +63,10 @@ class MayaBlockchainApiImpl @Inject constructor( ) : MayaBlockchainApi { companion object { private val log: Logger = LoggerFactory.getLogger(MayaBlockchainApiImpl::class.java) + // Maximum bytes the DASH OP_RETURN can hold (enforced by + // ScriptBuilder.createOpReturnScript). A Maya swap memo longer than this would + // otherwise crash with an IllegalArgumentException inside the builder. + private const val MAX_OP_RETURN_BYTES = 80 } override suspend fun commitSwapTransaction( @@ -95,6 +99,22 @@ class MayaBlockchainApiImpl @Inject constructor( val sendRequest: SendRequest val memo = swapTradeUIModel.memo ?: "=:${swapTradeUIModel.outputAsset}:${swapTradeUIModel.destinationAddress}" + + // Guard the OP_RETURN size before building the script. ScriptBuilder + // .createOpReturnScript throws an IllegalArgumentException (with a null + // message) for payloads over MAX_OP_RETURN_BYTES; fail cleanly instead so the + // UI can surface a real error. Long token identifiers (e.g. an asset contract + // address plus the destination address) are what push a memo past the limit. + val memoBytes = memo.toByteArray() + if (memoBytes.size > MAX_OP_RETURN_BYTES) { + log.error("maya swap memo too long: {} bytes (max {}): {}", memoBytes.size, MAX_OP_RETURN_BYTES, memo) + return ResponseResource.Failure( + MayaException("swap memo too long for OP_RETURN: ${memoBytes.size} > $MAX_OP_RETURN_BYTES bytes"), + false, + 0, + null + ) + } val tx = Transaction(params) // set outputs according to: @@ -228,6 +248,9 @@ class MayaBlockchainApiImpl @Inject constructor( return ResponseResource.Success(swapTradeUIModel) } catch (e: InsufficientMoneyException) { return ResponseResource.Failure(e, false, 0, e.message) + } catch (e: Exception) { + log.error("failed to build/send maya swap transaction", e) + return ResponseResource.Failure(e, false, 0, e.message) } } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RouteProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RouteProvider.kt new file mode 100644 index 0000000000..6a1b40e2c8 --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RouteProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.api + +/** + * The cross-chain swap provider that routes a DASH→asset swap. For assets routable + * via a single provider this is known statically from the token-list classification; + * for assets routable via BOTH it is resolved asynchronously by an indicative quote + * (the SwapKit-recommended route) — see + * [org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator]. + */ +enum class RouteProvider { + MAYA, + NEAR +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt index d79bc3f0b4..38be2d1544 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt @@ -19,18 +19,16 @@ package org.dash.wallet.integrations.maya.api import android.content.Intent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.bitcoinj.utils.Fiat import org.dash.wallet.common.data.ResponseResource -import org.dash.wallet.common.util.toBigDecimal -import org.dash.wallet.common.util.toFiat import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.InboundAddress import org.dash.wallet.integrations.maya.model.PoolInfo import org.dash.wallet.integrations.maya.model.SwapQuote import org.dash.wallet.integrations.maya.model.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeUIModel -import java.math.BigDecimal /** * Backend-agnostic surface for cross-chain swaps. @@ -43,12 +41,26 @@ import java.math.BigDecimal * are reused as the common DTO shape. SwapKit responses are mapped onto these on the * provider side; only the fields the wallet UI consumes need to be populated. */ +/** Shared empty default for backends that don't resolve per-asset route providers. */ +private val EMPTY_PREFERRED_ROUTE_PROVIDERS: StateFlow> = + MutableStateFlow(emptyMap()) + interface SwapProvider { val poolInfoList: StateFlow> val apiError: StateFlow var notificationIntent: Intent? var showNotificationOnResult: Boolean + /** + * SwapKit only: asset identifier → the recommended [RouteProvider] for assets + * routable via BOTH Maya and NEAR, resolved asynchronously by an indicative quote + * after the pool list is published. Assets absent from the map (single-provider, + * not-yet-resolved, or Maya-halted) have no calculated preference. Empty for the + * native Maya backend. + */ + val preferredRouteProviders: StateFlow> + get() = EMPTY_PREFERRED_ROUTE_PROVIDERS + suspend fun reset() fun observePoolList(fiatExchangeRate: Fiat): Flow> @@ -89,4 +101,4 @@ interface SwapProvider { suspend fun getUserAccounts(currency: String): List fun applyPoolPrices(pools: List, usdToFiat: Fiat) -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt index 94c1a63523..1f862711ef 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt @@ -104,7 +104,6 @@ abstract class MayaModule { .build() .create(SwapKitEndpoint::class.java) } - } @Binds @@ -128,4 +127,4 @@ abstract class MayaModule { @Binds @Singleton abstract fun bindSwapProvider(impl: DispatchingSwapProvider): SwapProvider -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/PoolInfo.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/PoolInfo.kt index 0db6c5301b..5c83175462 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/PoolInfo.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/PoolInfo.kt @@ -46,6 +46,37 @@ data class PoolInfo( @IgnoredOnParcel var assetPriceFiat: Fiat = Fiat.valueOf(MayaConstants.DEFAULT_EXCHANGE_CURRENCY, 0) + /** + * SwapKit only: true when this asset is routable from DASH **exclusively via + * MAYACHAIN** (no NEAR/other-provider fallback). Such assets inherit Maya's + * halt exposure and the OP_RETURN-memo constraint. Computed by + * [org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator] from the + * provider token-list intersection (see SWAPKIT_PROTOCOL.md → "Detecting + * Maya-only Assets"). Always false for the native Maya backend, where every + * asset is Maya-routed by definition. + */ + @IgnoredOnParcel + var mayaOnly: Boolean = false + + /** + * SwapKit only: true when this asset is routable from DASH **exclusively via + * NEAR** (in NEAR's token list but NOT MAYACHAIN's) — the mirror of [mayaOnly]. + * When BOTH [mayaOnly] and [nearOnly] are false the asset is routable via both + * providers (or via neither, for an unclassified reachable asset), and the + * picker shows no route-provider label. Always false for the native Maya backend. + */ + @IgnoredOnParcel + var nearOnly: Boolean = false + + /** + * SwapKit only: true when [mayaOnly] and Maya currently reports this asset's + * chain as halted / trading-paused (or global trading paused). For non-Maya-only + * assets this stays false because a NEAR route keeps them tradable even during a + * Maya halt. + */ + @IgnoredOnParcel + var mayaHalted: Boolean = false + @IgnoredOnParcel val assetPriceInCacao: BigDecimal get() { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt index a551a8e48f..44ea4f0af5 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/SwapTradeResponse.kt @@ -81,7 +81,7 @@ data class SwapTradeUIModel( var memo: String? = null, var txid: Sha256Hash = Sha256Hash.ZERO_HASH, var expectedOutputAmount: BigDecimal = BigDecimal.ZERO, - val routeName: String? = "maya-default", + val routeName: String? = "", val availableRoutes: List = listOf() ) : Parcelable { @IgnoredOnParcel diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt index 2cc5dbdb7e..441a7b645d 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt @@ -465,7 +465,7 @@ open class MayaStarknetCryptoCurrency : MayaBitcoinCryptoCurrency() { override val name: String = "Starknet" override val asset: String = "STRK.STRK" override val exampleAddress: String = - "0x05dcaeae5fde9a4cdb44ea21cba29ad9e6e0c1e9ae7e7e2b6b2f0f6e2e3e4e5e6" + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" override val paymentIntentParser: PaymentIntentParser = StarknetPaymentIntentParser() override val addressParser: AddressParser = StarknetAddressParser() override val codeId: Int = R.string.cryptocurrency_strk_code @@ -1119,7 +1119,7 @@ object MayaCurrencyList { // ----- SOL chain tokens ----- MayaSolanaTokenCryptoCurrency( - "WIF", + "\$WIF", "dogwifhat", "SOL.\$WIF-EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", "SOL.WIF-zcjm", diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt index 8d19f01f46..851c7e0f23 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt @@ -18,26 +18,29 @@ package org.dash.wallet.integrations.maya.swapkit import android.content.Intent +import com.google.gson.Gson import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.script.ScriptPattern +import org.bitcoinj.core.InsufficientMoneyException import org.bitcoinj.utils.Fiat import org.dash.wallet.common.WalletDataProvider import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.services.SendPaymentService import org.dash.wallet.common.util.toBigDecimal +import org.dash.wallet.common.util.toCoin import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.BuildConfig import org.dash.wallet.integrations.maya.api.MayaBlockchainApi import org.dash.wallet.integrations.maya.api.MayaException +import org.dash.wallet.integrations.maya.api.MayaWebApi +import org.dash.wallet.integrations.maya.api.RouteProvider import org.dash.wallet.integrations.maya.api.SwapProvider -import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.Account +import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.Balance import org.dash.wallet.integrations.maya.model.InboundAddress import org.dash.wallet.integrations.maya.model.PoolInfo @@ -45,12 +48,12 @@ import org.dash.wallet.integrations.maya.model.SwapFees import org.dash.wallet.integrations.maya.model.SwapQuote import org.dash.wallet.integrations.maya.model.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeUIModel -import org.dash.wallet.integrations.maya.utils.MayaConstants import org.dash.wallet.integrations.maya.swapkit.model.SwapKitFee import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteRequest import org.dash.wallet.integrations.maya.swapkit.model.SwapKitRoute import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapRequest -import org.dash.wallet.integrations.maya.ui.MayaViewModel +import org.dash.wallet.integrations.maya.utils.MayaConfig +import org.dash.wallet.integrations.maya.utils.MayaConstants import org.slf4j.LoggerFactory import java.math.BigDecimal import java.math.RoundingMode @@ -70,42 +73,112 @@ import javax.inject.Inject * populated, with [PoolInfo.assetPriceFiat] computed directly from `/price` rather * than the stable-pool cross-product Maya uses. * - * The DASH transaction itself is still built by [MayaBlockchainApi.buildAndSendSwapTx] - * — SwapKit's `/v3/swap` response yields the same `vaultAddress` + `memo` shape the - * existing builder needs, so no PSBT parsing is required for DASH-as-source. + * The DASH transaction is built per route provider: Maya routes reuse + * [MayaBlockchainApi.buildAndSendSwapTx] (vault + OP_RETURN memo), while non-Maya + * routes (NEAR Intents) use [buildAndSendDepositTx], a plain deposit to the SwapKit + * deposit address with no memo. Either way no PSBT parsing is required for + * DASH-as-source. */ class SwapKitApiAggregator @Inject constructor( private val webApi: SwapKitWebApi, private val blockchainApi: MayaBlockchainApi, - private val walletDataProvider: WalletDataProvider + private val walletDataProvider: WalletDataProvider, + // Source of truth for Maya halt status (mayanode /inbound_addresses). Used only + // to enrich Maya-only assets — see markMayaInfo(). The coupling to Maya is + // acceptable because these assets settle exclusively via Maya on SwapKit + // (SWAPKIT_PROTOCOL.md → "Detecting Maya-only Assets"). + private val mayaWebApi: MayaWebApi, + // Persists the coin-list snapshot for instant cold-start rendering (SWR). + private val mayaConfig: MayaConfig, + // Builds/signs/broadcasts the plain DASH deposit for non-Maya routes (NEAR Intents). + private val sendPaymentService: SendPaymentService ) : SwapProvider { companion object { private val log = LoggerFactory.getLogger(SwapKitApiAggregator::class.java) private val UPDATE_FREQ_MS = TimeUnit.SECONDS.toMillis(30) private val DASH_BASE_UNITS = BigDecimal("100000000") // 1e8 + + // The Maya/NEAR token-list classification (/tokens) reflects provider + // capability, which changes very rarely — cache it far longer than the 30s + // pool refresh so we don't hit /tokens twice every cycle. + private val CLASSIFICATION_TTL_MS = TimeUnit.HOURS.toMillis(6) + + // A both-provider asset's recommended network ($50 quote) is stable, so cache + // each resolution and only re-query once it ages past this — instead of + // re-quoting every both-asset on every 30s pool refresh. + private val PREFERRED_ROUTE_TTL_MS = TimeUnit.MINUTES.toMillis(10) + + // Indicative quote size used to pick the recommended network for assets + // routable via BOTH Maya and NEAR — $50 worth of DASH. + private val PREFERRED_QUOTE_USD = BigDecimal("50") + + // TEMP TEST FLAG — when true (debug builds only), every Maya-only asset is + // rendered as halted so the "Halted" chip / disabled state / toast can be + // verified without waiting for a real Maya halt on a listed Maya-only coin + // (the only live Maya halts—XRD/ZEC—are not Maya-only here). Set back to + // false or delete the forcedHalt branch in markMayaInfo() before merging. + private const val DEBUG_FORCE_MAYA_ONLY_HALT = false } override val poolInfoList = MutableStateFlow>(emptyList()) override val apiError = MutableStateFlow(null) + override val preferredRouteProviders = MutableStateFlow>(emptyMap()) override var notificationIntent: Intent? = null override var showNotificationOnResult: Boolean = false private val responseScope = CoroutineScope( Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) + private val gson = Gson() private var poolListLastUpdated: Long = 0L + // When the Maya/NEAR classification was last fetched from /tokens; gates the + // CLASSIFICATION_TTL_MS reuse window. 0 = never (forces a fetch). + private var classificationLastUpdated: Long = 0L + + // asset → when its preferred network was last resolved; gates PREFERRED_ROUTE_TTL_MS. + private val preferredRouteResolvedAt = mutableMapOf() + // Asset → USD price, captured at refresh time. applyPoolPrices re-seeds from // this cache so it stays idempotent across re-emissions AND handles // selected-currency switches without re-fetching from SwapKit. private val usdPriceCache = mutableMapOf() + // Upper-cased identifiers routable from DASH only via MAYACHAIN (no NEAR + // fallback). Refreshed alongside the pool list. Empty until first refresh. + private var mayaOnlyAssets: Set = emptySet() + + // Upper-cased identifiers routable from DASH only via NEAR (no MAYACHAIN + // fallback) — the mirror of [mayaOnlyAssets]. Assets in neither set are routable + // via both providers. Refreshed alongside the pool list. + private var nearOnlyAssets: Set = emptySet() + + // Maya chain → halted (chain halted OR global trading paused). Captured at + // refresh time from mayanode /inbound_addresses; used to stamp pool.mayaHalted. + private var mayaHaltedChains: Set = emptySet() + private var mayaGlobalHalt: Boolean = false + + init { + // Hydrate from the persisted snapshot so the picker renders instantly on cold + // start; observePoolList still triggers a background refresh (poolListLastUpdated + // stays 0), so this is stale-while-revalidate. + responseScope.launch { hydrateFromSnapshot() } + } + override suspend fun reset() { log.info("swapkit reset") poolInfoList.value = emptyList() apiError.value = null poolListLastUpdated = 0L usdPriceCache.clear() + mayaOnlyAssets = emptySet() + nearOnlyAssets = emptySet() + classificationLastUpdated = 0L + mayaHaltedChains = emptySet() + mayaGlobalHalt = false + preferredRouteProviders.value = emptyMap() + preferredRouteResolvedAt.clear() + runCatching { mayaConfig.set(MayaConfig.SWAPKIT_POOL_SNAPSHOT, "") } } override fun observePoolList(fiatExchangeRate: Fiat): Flow> { @@ -139,6 +212,11 @@ class SwapKitApiAggregator @Inject constructor( val prices = webApi.getPrices(identifiers) .associateBy({ it.identifier.uppercase() }, { it.priceUsd }) + // Refresh the Maya-only classification and Maya halt status before building + // the pools so each PoolInfo can be stamped in a single pass. + refreshMayaOnlyClassification() + refreshMayaHaltStatus() + // Populate `assetPriceFiat` with the raw USD price (stored as a Fiat with code "USD"). // [applyPoolPrices] then converts USD → selected fiat in a second pass — same // contract Maya uses (raw price in pools, fiat conversion in the ViewModel pipeline). @@ -156,9 +234,241 @@ class SwapKitApiAggregator @Inject constructor( } PoolInfo(asset = identifier, status = "Available").also { it.assetPriceFiat = priceUsdFiat + markMayaInfo(it) } } poolInfoList.value = pools + + // Snapshot the fresh list + classification for instant cold-start rendering. + persistSnapshot() + + // Resolve the recommended network for both-provider assets in the background + // so the picker can replace "Multiple networks" with the actual provider. + // Launched separately so it never delays publishing the pool list above. + responseScope.launch { resolvePreferredRouteProviders(pools) } + } + + /** + * For assets routable via BOTH Maya and NEAR (and not Maya-halted), fetch an + * indicative DASH→asset quote worth [PREFERRED_QUOTE_USD] and record the + * SwapKit-recommended route's provider. Emits incrementally so the picker updates + * each row as it resolves. Maya-only / NEAR-only assets are skipped (their label is + * already known statically); Maya-halted assets are skipped per product spec. + * + * Each resolution is cached for [PREFERRED_ROUTE_TTL_MS], so a 30s pool refresh only + * re-quotes assets that are new or whose recommendation has aged out — not the whole + * both-provider set every cycle. + */ + private suspend fun resolvePreferredRouteProviders(pools: List) { + val dashUsd = usdPriceCache[SwapKitConstants.DASH_ASSET] + if (dashUsd == null || dashUsd.signum() <= 0) { + log.info("swapkit preferred-route: no DASH price yet; skipping resolution") + return + } + val sellAmount = PREFERRED_QUOTE_USD + .divide(dashUsd, 8, RoundingMode.HALF_UP) + .toPlainString() + + val now = System.currentTimeMillis() + val candidates = pools.filter { pool -> + pool.asset != SwapKitConstants.DASH_ASSET && + !pool.mayaOnly && + !pool.nearOnly && + !mayaGlobalHalt && + !mayaHaltedChains.contains(pool.asset.substringBefore('.').uppercase()) && + isPreferredRouteStale(pool.asset, now) + } + if (candidates.isEmpty()) return + log.info("swapkit preferred-route: resolving {} both-provider assets", candidates.size) + + val resolved = preferredRouteProviders.value.toMutableMap() + var changed = false + for (pool in candidates) { + val provider = recommendedProviderFor(pool.asset, sellAmount) ?: continue + resolved[pool.asset] = provider + preferredRouteResolvedAt[pool.asset] = System.currentTimeMillis() + changed = true + // Emit a fresh map per asset so the StateFlow re-emits and the picker + // updates this row from "Multiple networks" to the resolved provider. + preferredRouteProviders.value = resolved.toMap() + } + log.info("swapkit preferred-route: resolved {}", preferredRouteProviders.value) + // Persist the enriched snapshot (now with preferred routes) once, not per asset. + if (changed) persistSnapshot() + } + + private fun isPreferredRouteStale(asset: String, now: Long): Boolean { + val resolvedAt = preferredRouteResolvedAt[asset] ?: return true + return now - resolvedAt > PREFERRED_ROUTE_TTL_MS + } + + /** + * One indicative quote with no provider filter → SwapKit returns routes for every + * provider; the recommended route's [SwapKitRoute.providers] identifies the network. + */ + private suspend fun recommendedProviderFor(asset: String, sellAmount: String): RouteProvider? { + val response = webApi.getQuote( + SwapKitQuoteRequest( + sellAsset = SwapKitConstants.DASH_ASSET, + buyAsset = asset, + sellAmount = sellAmount, + slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT + ) + ) ?: return null + val providers = response.routes.bestRoute()?.providers ?: return null + return when { + providers.any { it.uppercase().contains("MAYA") } -> RouteProvider.MAYA + providers.any { it.uppercase().contains("NEAR") } -> RouteProvider.NEAR + else -> null + } + } + + /** + * Restore the last persisted coin-list snapshot into memory so the picker can + * render immediately on cold start. Only applies while no fresh data is present; + * deliberately leaves [poolListLastUpdated] at 0 so [observePoolList] still triggers + * a background refresh (stale-while-revalidate). Best-effort: any failure is ignored. + */ + private suspend fun hydrateFromSnapshot() { + if (poolInfoList.value.isNotEmpty()) return + val raw = runCatching { mayaConfig.get(MayaConfig.SWAPKIT_POOL_SNAPSHOT) }.getOrNull() + if (raw.isNullOrBlank()) return + val snapshot = runCatching { gson.fromJson(raw, SwapKitSnapshot::class.java) }.getOrNull() + if (snapshot == null || snapshot.pools.isEmpty()) return + // Don't clobber data that landed while we were reading from disk. + if (poolInfoList.value.isNotEmpty()) return + + usdPriceCache.clear() + mayaOnlyAssets = snapshot.mayaOnly.toSet() + nearOnlyAssets = snapshot.nearOnly.toSet() + classificationLastUpdated = snapshot.classificationAtMs + + val pools = snapshot.pools.map { p -> + PoolInfo(asset = p.asset, status = "Available").also { pool -> + if (p.priceUsd > 0.0) { + val priceBd = BigDecimal(p.priceUsd) + usdPriceCache[p.asset] = priceBd + pool.assetPriceFiat = priceBd.toFiat(MayaConstants.DEFAULT_EXCHANGE_CURRENCY) + } + pool.mayaOnly = p.mayaOnly + pool.nearOnly = p.nearOnly + pool.mayaHalted = p.mayaHalted + } + } + preferredRouteProviders.value = snapshot.preferredRoutes + .mapNotNull { (asset, name) -> + runCatching { asset to RouteProvider.valueOf(name) }.getOrNull() + } + .toMap() + // Seed resolution timestamps so the TTL spans cold start — the background + // refresh only re-quotes preferred routes once they age past the TTL. + preferredRouteProviders.value.keys.forEach { asset -> + preferredRouteResolvedAt[asset] = snapshot.savedAtMs + } + poolInfoList.value = pools + log.info("swapkit: hydrated {} pools from snapshot", pools.size) + } + + /** Persist the current in-memory list + classification + preferred routes. */ + private suspend fun persistSnapshot() { + val pools = poolInfoList.value + if (pools.isEmpty()) return + val snapshot = SwapKitSnapshot( + savedAtMs = System.currentTimeMillis(), + classificationAtMs = classificationLastUpdated, + pools = pools.map { pool -> + SwapKitPoolSnapshot( + asset = pool.asset, + priceUsd = (usdPriceCache[pool.asset] ?: BigDecimal.ZERO).toDouble(), + mayaOnly = pool.mayaOnly, + nearOnly = pool.nearOnly, + mayaHalted = pool.mayaHalted + ) + }, + mayaOnly = mayaOnlyAssets.toList(), + nearOnly = nearOnlyAssets.toList(), + preferredRoutes = preferredRouteProviders.value.mapValues { it.value.name } + ) + runCatching { mayaConfig.set(MayaConfig.SWAPKIT_POOL_SNAPSHOT, gson.toJson(snapshot)) } + .onFailure { log.info("swapkit: failed to persist snapshot: {}", it.message) } + } + + /** + * Refresh [mayaOnlyAssets] and [nearOnlyAssets]: which provider(s) can route each + * identifier from DASH. Method per SWAPKIT_PROTOCOL.md → "Detecting Maya-only + * Assets": an asset is Maya-only when it is in MAYACHAIN's token list but NOT + * NEAR's; NEAR-only is the mirror. Assets in both lists are routable via either + * provider and end up in neither set. Compared case-insensitively on the canonical + * `identifier`. On failure (either list empty) the previous classification is kept + * rather than wrongly clearing it. + */ + private suspend fun refreshMayaOnlyClassification() { + // Provider capability changes rarely; reuse the cached classification within + // CLASSIFICATION_TTL_MS instead of hitting /tokens (×2) on every 30s refresh. + val now = System.currentTimeMillis() + val fresh = classificationLastUpdated != 0L && now - classificationLastUpdated < CLASSIFICATION_TTL_MS + if (fresh && (mayaOnlyAssets.isNotEmpty() || nearOnlyAssets.isNotEmpty())) { + return + } + val mayaIds = webApi.getTokens(SwapKitConstants.MAYACHAIN_PROVIDER) + .map { it.identifier.uppercase() } + .toSet() + val nearIds = webApi.getTokens(SwapKitConstants.NEAR_PROVIDER) + .map { it.identifier.uppercase() } + .toSet() + if (mayaIds.isEmpty() || nearIds.isEmpty()) { + log.info( + "swapkit route classification: token list unavailable (maya={}, near={}); keeping previous sets", + mayaIds.size, + nearIds.size + ) + return + } + mayaOnlyAssets = mayaIds - nearIds + nearOnlyAssets = nearIds - mayaIds + classificationLastUpdated = now + log.info( + "swapkit route classification: maya-only={} {}, near-only={}", + mayaOnlyAssets.size, + mayaOnlyAssets, + nearOnlyAssets.size + ) + } + + /** + * Refresh Maya halt status from mayanode /inbound_addresses. Captures per-chain + * `halted`/`chainTradingPaused` and the global `globalTradingPaused` flag, which + * together drive [PoolInfo.mayaHalted] for Maya-only assets. + */ + private suspend fun refreshMayaHaltStatus() { + val inbound = mayaWebApi.getInboundAddresses() + if (inbound.isEmpty()) { + log.info("swapkit maya halt: no inbound addresses; keeping previous halt status") + return + } + mayaGlobalHalt = inbound.any { it.globalTradingPaused } + mayaHaltedChains = inbound + .filter { it.halted || it.chainTradingPaused || it.globalTradingPaused } + .map { it.chain.uppercase() } + .toSet() + log.info("swapkit maya halt: global={} chains={}", mayaGlobalHalt, mayaHaltedChains) + } + + /** + * Stamp [PoolInfo.mayaOnly] and [PoolInfo.mayaHalted] from the cached + * classification + halt status. `mayaHalted` is only meaningful for Maya-only + * assets — others have a NEAR route and stay tradable through a Maya halt. + */ + private fun markMayaInfo(pool: PoolInfo) { + val isMayaOnly = mayaOnlyAssets.contains(pool.asset.uppercase()) + pool.mayaOnly = isMayaOnly + pool.nearOnly = nearOnlyAssets.contains(pool.asset.uppercase()) + val chainHalted = mayaGlobalHalt || + mayaHaltedChains.contains(pool.asset.substringBefore('.').uppercase()) + // TEMP TEST: force Maya-only assets to halted in debug builds — see + // DEBUG_FORCE_MAYA_ONLY_HALT. Remove this branch before merging. + val forcedHalt = BuildConfig.DEBUG && DEBUG_FORCE_MAYA_ONLY_HALT + pool.mayaHalted = isMayaOnly && (forcedHalt || chainHalted) } override suspend fun getInboundAddresses(): List { @@ -210,24 +520,17 @@ class SwapKitApiAggregator @Inject constructor( override suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource { val sellAmount = swapRequest.amount.dash.setScale(8, RoundingMode.HALF_UP).toPlainString() - val sourceAddress = walletDataProvider.wallet?.currentReceiveAddress()?.toBase58() - ?: return ResponseResource.Failure(MayaException("wallet not loaded"), false, 0, null) - - val map = hashMapOf() - walletDataProvider.wallet!!.unspents.forEach { output -> - when { - ScriptPattern.isP2PKH(output.scriptPubKey) -> ScriptPattern.extractHashFromP2PKH(output.scriptPubKey) - else -> null - }?.let { - val address = Address.fromPubKeyHash(walletDataProvider.networkParameters, it) - map.computeIfPresent(address) { _, value -> output.value + value } - map.computeIfAbsent(address) { - output.value - } - } + if (walletDataProvider.wallet == null) { + return ResponseResource.Failure(MayaException("wallet not loaded"), false, 0, null) } - val maxAddressBalance = map.values.maxOf { it } - val address = map.entries.find { maxAddressBalance == it.value }?.key + // Refund / source address reported to SwapKit. Use the current receive address + // rather than the wallet's largest-balance UTXO: SwapKit logs this value, and for + // NEAR routes it is where refunds land, so sending the richest address would link + // the swap to the user's main holdings. The current receive address is unused + // until funded and still wallet-owned, so any NEAR refund is recoverable. Safe now + // that disableBuildTx=true skips SwapKit's per-address balance check — the only + // reason the max-balance address was originally required. + val sourceAddress = walletDataProvider.currentReceiveAddress().toBase58() val quote = webApi.getQuote( SwapKitQuoteRequest( @@ -266,7 +569,7 @@ class SwapKitApiAggregator @Inject constructor( val swap = webApi.postSwap( SwapKitSwapRequest( routeId = route.routeId, - sourceAddress = address?.toBase58() ?: sourceAddress, + sourceAddress = sourceAddress, destinationAddress = swapRequest.targetAddress, disableBalanceCheck = true, disableBuildTx = true @@ -287,7 +590,7 @@ class SwapKitApiAggregator @Inject constructor( val vault = swap.targetAddress ?: swap.inboundAddress ?: return ResponseResource.Failure(MayaException("swapkit returned no vault address"), false, 0, null) val memo = swap.memo - //?: return ResponseResource.Failure(MayaException("swapkit returned no memo"), false, 0, null) + // ?: return ResponseResource.Failure(MayaException("swapkit returned no memo"), false, 0, null) val feeAmount = swapRequest.amount.copy().apply { // Sum the SwapKit fee breakdown, converting each leg to DASH via the @@ -305,6 +608,12 @@ class SwapKitApiAggregator @Inject constructor( // destroys the user's actual sell amount. The preview shows the pool-price // crypto estimate; what actually arrives is the on-chain payout. + // SwapKit reports the buy amount as a human-unit decimal string (e.g. + // "0.081361035"), unlike legacy Maya whose expectedAmountOut was in base + // units — so no 1e8 division here. Drives the "To" row on the order preview. + val expectedOutputAmount = (swap.expectedBuyAmount ?: route.expectedBuyAmount) + .toBigDecimalOrNull() ?: BigDecimal.ZERO + val result = SwapTradeUIModel( amount = swapRequest.amount, outputAsset = swapRequest.target_maya_asset, @@ -313,6 +622,7 @@ class SwapKitApiAggregator @Inject constructor( destinationAddress = swapRequest.targetAddress, memo = memo, maximum = swapRequest.maximum, + expectedOutputAmount = expectedOutputAmount, routeName = route.providers.joinToString(","), availableRoutes = quote.routes.map { "${it.providers.joinToString(",")}: ${it.meta?.tags ?: listOf() }" } ) @@ -336,12 +646,78 @@ class SwapKitApiAggregator @Inject constructor( ) ) return if (refreshed is ResponseResource.Success) { - blockchainApi.buildAndSendSwapTx(refreshed.value) + if (isMayaRoute(refreshed.value)) { + // Maya route: DASH → Asgard vault with the swap memo as an OP_RETURN. + blockchainApi.buildAndSendSwapTx(refreshed.value) + } else { + // NEAR Intents (or any non-Maya provider): a plain DASH deposit to the + // one-time SwapKit deposit address — no MAYAChain OP_RETURN memo. Using + // the Maya builder here would fabricate a Maya-format memo (Maya doesn't + // route the asset, so SwapKit returns no memo and the builder falls back + // to "=:ASSET:DEST") and overflow the 80-byte OP_RETURN limit for long + // token identifiers such as TRON.USDT-. + buildAndSendDepositTx(refreshed.value) + } } else { refreshed } } + /** + * True when the resolved route settles through MAYAChain. Mirrors the provider + * classification in [recommendedProviderFor]: [SwapTradeUIModel.routeName] carries + * the comma-joined SwapKit provider names (e.g. "MAYACHAIN"). + */ + private fun isMayaRoute(model: SwapTradeUIModel): Boolean = + model.routeName?.contains("MAYA", ignoreCase = true) == true + + /** + * Builds + signs + broadcasts a plain DASH deposit to the SwapKit deposit address for + * non-Maya routes (NEAR Intents). The deposit address itself identifies the swap, so + * no OP_RETURN memo is attached — avoiding the Maya-only vault output ordering and the + * 80-byte OP_RETURN limit. + */ + private suspend fun buildAndSendDepositTx( + swapTradeUIModel: SwapTradeUIModel + ): ResponseResource { + return try { + val params = walletDataProvider.networkParameters + val depositAddress = Address.fromBase58(params, swapTradeUIModel.vaultAddress) + // Deposit amount = sellAmount + swap fee, matching the total shown in the + // preview (amount + feeAmount) and the Maya vault path + // (MayaBlockchainApi.buildAndSendSwapTx). The DASH mining fee is funded + // separately by the wallet's coin selection. emptyWallet (maximum) ignores + // this value and sweeps the balance instead. + val amount = if (!swapTradeUIModel.maximum) { + swapTradeUIModel.amount.dash + swapTradeUIModel.feeAmount.dash + } else { + swapTradeUIModel.amount.dash + }.setScale(8, RoundingMode.HALF_UP).toCoin() + + // NEAR Intents UTXO deposits carry no memo. Warn if SwapKit unexpectedly + // returned one so we notice if a provider ever starts requiring it. + if (!swapTradeUIModel.memo.isNullOrBlank()) { + log.warn("non-Maya deposit route returned a memo; not encoding it: {}", swapTradeUIModel.memo) + } + + log.info("swapkit deposit: {} to {}", amount.toFriendlyString(), depositAddress) + val sentTransaction = sendPaymentService.sendCoins( + depositAddress, + amount, + emptyWallet = swapTradeUIModel.maximum, + // Mirror the Maya builder, which bypasses leftover-balance checks for swaps. + checkBalanceConditions = false + ) + swapTradeUIModel.txid = sentTransaction.txId + ResponseResource.Success(swapTradeUIModel) + } catch (e: InsufficientMoneyException) { + ResponseResource.Failure(e, false, 0, e.message) + } catch (e: Exception) { + log.error("failed to build/send swapkit deposit transaction", e) + ResponseResource.Failure(e, false, 0, e.message) + } + } + override suspend fun getUserAccounts(currency: String): List { return listOf( AccountDataUIModel( @@ -475,7 +851,10 @@ class SwapKitApiAggregator @Inject constructor( // undercounting is preferable to guessing. log.info( "swapkit fee skipped: type={} amount={} asset={} chain={}", - fee.type, fee.amount, fee.asset, fee.chain + fee.type, + fee.amount, + fee.asset, + fee.chain ) BigDecimal.ZERO } @@ -513,4 +892,27 @@ class SwapKitApiAggregator @Inject constructor( log.info("$priceUsd, ${pool.assetPriceFiat} -> ${pool.asset}") } } -} \ No newline at end of file +} + +/** + * Persisted coin-list snapshot for stale-while-revalidate cold-start rendering. Stored + * as JSON under [MayaConfig.SWAPKIT_POOL_SNAPSHOT]. Only the fields the picker needs to + * render and re-seed the in-memory caches are kept; prices/halt are deliberately stale + * until the background refresh lands. [RouteProvider] is stored by name. + */ +private data class SwapKitSnapshot( + val savedAtMs: Long = 0L, + val classificationAtMs: Long = 0L, + val pools: List = emptyList(), + val mayaOnly: List = emptyList(), + val nearOnly: List = emptyList(), + val preferredRoutes: Map = emptyMap() +) + +private data class SwapKitPoolSnapshot( + val asset: String = "", + val priceUsd: Double = 0.0, + val mayaOnly: Boolean = false, + val nearOnly: Boolean = false, + val mayaHalted: Boolean = false +) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt index 20b034796f..1f97da33f7 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitAuthInterceptor.kt @@ -37,4 +37,4 @@ class SwapKitAuthInterceptor(private val apiKey: String) : Interceptor { .build() return chain.proceed(authed) } -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt index 2755e9abff..e8650bbd43 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt @@ -25,7 +25,18 @@ object SwapKitConstants { const val DASH_ASSET = "DASH.DASH" /** Default slippage (percent) for indicative quotes. */ - const val DEFAULT_SLIPPAGE_PERCENT = 3 + const val DEFAULT_SLIPPAGE_PERCENT = 2 + + /** + * The only two providers that route DASH (verified from `/providers`: + * the sole entries whose `supportedChainIds` contain `dash`). An asset is + * "Maya-only" when MAYACHAIN can route it but NEAR cannot — see + * SWAPKIT_PROTOCOL.md → "Detecting Maya-only Assets". + */ + const val NEAR_PROVIDER = "NEAR" + + /** MAYACHAIN non-streaming returns no token list; only the streaming variant does. */ + const val MAYACHAIN_PROVIDER = "MAYACHAIN_STREAMING" /** * SwapKit API key, sourced from `service.properties` (SWAPKIT_API_KEY) at build @@ -34,4 +45,4 @@ object SwapKitConstants { * a key. */ const val API_KEY: String = BuildConfig.SWAPKIT_API_KEY -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt index 24cb10cb38..f9dea98dab 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitEndpoint.kt @@ -54,4 +54,4 @@ interface SwapKitEndpoint { @POST("price") suspend fun postPrice(@Body request: SwapKitPriceRequest): Response> -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt index 5aa58e031d..6e59f53e5e 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitWebApi.kt @@ -138,4 +138,4 @@ open class SwapKitWebApi @Inject constructor( fallback } } -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt index 4f5b509c26..43a7b960b9 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/model/SwapKitModels.kt @@ -152,4 +152,4 @@ data class SwapKitPriceItem( val provider: String? = null, @SerializedName("price_usd") val priceUsd: Double = 0.0, val timestamp: Long? = null -) \ No newline at end of file +) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt index bdae03359f..1f33159aaa 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputViewModel.kt @@ -12,6 +12,7 @@ import org.dash.wallet.common.integrations.ExchangeIntegration import org.dash.wallet.common.integrations.ExchangeIntegrationProvider import org.dash.wallet.common.ui.address_input.AddressSource import org.dash.wallet.integrations.maya.api.SwapProvider +import org.dash.wallet.integrations.maya.payments.MayaCurrencyList import org.dash.wallet.integrations.maya.model.SwapQuote import javax.inject.Inject @@ -27,16 +28,29 @@ class MayaAddressInputViewModel @Inject constructor( val addressSources: Flow> get() = _addressSources.asStateFlow() - private fun refreshAddressSources(it: List) { - val sources = it.map { integration -> - AddressSource( - integration.id, - integration.name, - integration.iconId, - integration.address, - integration.currency - ) - } + private fun refreshAddressSources(integrations: List) { + // The selected [asset] (e.g. "TRON.USDT") pins the destination network. An + // exchange such as Coinbase may only support some networks for a coin (e.g. + // ERC-20 USDT, not TRON.USDT) and hand back a deposit address on the wrong + // network. Sending the swap output there would lose funds, so drop any + // connected source whose address doesn't validate against this asset's own + // parser. Sources without an address yet (not connected) are kept so the user + // can still connect. + val addressParser = if (::asset.isInitialized) MayaCurrencyList[asset]?.addressParser else null + val sources = integrations + .filter { integration -> + val address = integration.address + address == null || addressParser == null || addressParser.exactMatch(address.trim()) + } + .map { integration -> + AddressSource( + integration.id, + integration.name, + integration.iconId, + integration.address, + integration.currency + ) + } _addressSources.value = sources } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt index 89f75ce0d5..6e75cd6d8e 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewFragment.kt @@ -48,12 +48,14 @@ import org.dash.wallet.common.util.Constants import org.dash.wallet.common.util.GenericUtils import org.dash.wallet.common.util.observe import org.dash.wallet.common.util.safeNavigate +import org.dash.wallet.integrations.maya.BuildConfig import org.dash.wallet.integrations.maya.R import org.dash.wallet.integrations.maya.databinding.FragmentMayaConversionPreviewBinding import org.dash.wallet.integrations.maya.model.CurrencyInputType import org.dash.wallet.integrations.maya.model.MayaResultType import org.dash.wallet.integrations.maya.model.SwapTradeUIModel import org.dash.wallet.integrations.maya.model.TransactionType +import org.dash.wallet.integrations.maya.swapkit.SwapKitConstants import org.dash.wallet.integrations.maya.ui.convert_currency.model.MayaTransactionParams import org.dash.wallet.integrations.maya.ui.dialogs.MayaResultDialog import java.math.BigDecimal @@ -174,6 +176,7 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion viewModel.swapTradeOrder.observe(viewLifecycleOwner) { newSwapOrderId = it.swapTradeId countDownTimer.start() + it.updateConversionPreviewUI() } viewModel.commitSwapTradeSuccessState.observe(viewLifecycleOwner) { params -> @@ -188,6 +191,7 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion ) ) } + observeNavigationCallBack() viewModel.onInsufficientMoneyCallback.observe(viewLifecycleOwner) { @@ -200,6 +204,23 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion } } + /** + * Map the raw provider string from [SwapTradeUIModel.routeName] (e.g. "maya-default", + * "MAYACHAIN_STREAMING", "NEAR", or a comma-joined list) to the user-facing route label + * shown in the currency picker. Mirrors the MAYA-then-NEAR classification in + * [org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator]; falls back to the raw + * value for anything unrecognised. + */ + private fun prettyRouteName(routeName: String?): String { + val raw = routeName?.trim().orEmpty() + return when { + raw.isEmpty() -> getString(R.string.maya_route_label_maya) + raw.contains("MAYA", ignoreCase = true) -> getString(R.string.maya_route_label_maya) + raw.contains("NEAR", ignoreCase = true) -> getString(R.string.maya_route_label_near) + else -> raw + } + } + private fun setNetworkState(hasInternet: Boolean) { if (!hasInternet) { if (networkStatusView == null) { @@ -306,7 +327,7 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion amount.anchoredType == CurrencyInputType.Fiat ) binding.contentOrderReview.inputAccountIcon - .load(GenericUtils.getCoinIcon(this.inputCurrency.lowercase())) { + .load(GenericUtils.getCoinIcon(this.inputCurrency.lowercase(), SwapKitConstants.DASH_ASSET)) { crossfade(true) scale(Scale.FILL) placeholder(org.dash.wallet.common.R.drawable.ic_default_flag) @@ -314,20 +335,28 @@ class MayaConversionPreviewFragment : Fragment(R.layout.fragment_maya_conversion } binding.contentOrderReview.convertOutputIcon - .load(GenericUtils.getCoinIcon(this.outputCurrency.lowercase())) { + .load(GenericUtils.getCoinIcon(this.outputCurrency.lowercase(), this.outputAsset)) { crossfade(true) scale(Scale.FILL) placeholder(org.dash.wallet.common.R.drawable.ic_default_flag) transformations(CircleCropTransformation()) } - val routeName = this.routeName - val routes = this.availableRoutes - binding.contentOrderReview.orderInfo.text = """ - selected: $routeName + binding.contentOrderReview.networkValue.text = prettyRouteName(this.routeName) - all: $routes - """.trimIndent() + // Route diagnostics are dev-only; hidden in release builds. + if (BuildConfig.DEBUG) { + val routeName = this.routeName + val routes = this.availableRoutes + binding.contentOrderReview.orderInfo.isVisible = true + binding.contentOrderReview.orderInfo.text = """ + selected: $routeName + + all: $routes + """.trimIndent() + } else { + binding.contentOrderReview.orderInfo.isVisible = false + } } private fun setValueWithCurrencyCodeOrSymbol( diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 0b754edd24..91a59e45d2 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -127,6 +127,12 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto proceedWithSwap(request) } + // While the quote is being fetched, block all amount input on the enter-amount + // screen so a late key press can't alter the value carried to the preview. + viewModel.showLoading.observe(viewLifecycleOwner) { loading -> + fragment.setProcessing(loading == true) + } + binding.authLimitBanner.warningLimitInfo.setOnClickListener { AdaptiveDialog.custom(R.layout.dialog_withdrawal_limit_info).show(requireActivity()) } @@ -159,7 +165,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto val paymentIntent = try { viewModel.getUpdatedPaymentIntent( convertViewModel.enteredConvertDashAmount.value!!, - Address.fromBase58(null, swapTrade.vaultAddress) + Address.fromBase58(viewModel.networkParameters, swapTrade.vaultAddress) ) } catch (e: Exception) { AdaptiveDialog.create( @@ -252,6 +258,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto val hasAmount = !amount.isZero binding.youWillReceiveLabel.isVisible = hasAmount binding.youWillReceiveValue.isVisible = hasAmount + updateReceiveNetwork(hasAmount) binding.convertView.dashInput = amount } @@ -259,12 +266,14 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto val hasAmount = !amount.isZero binding.youWillReceiveLabel.isVisible = hasAmount binding.youWillReceiveValue.isVisible = hasAmount + updateReceiveNetwork(hasAmount) binding.convertView.fiatInput = amount } convertViewModel.enteredConvertCryptoAmount.observe(viewLifecycleOwner) { amount -> binding.youWillReceiveLabel.isVisible = amount.second.isNotEmpty() binding.youWillReceiveValue.isVisible = amount.second.isNotEmpty() + updateReceiveNetwork(amount.second.isNotEmpty()) if (binding.convertView.dashToCrypto) { binding.youWillReceiveValue.text = getString( @@ -290,6 +299,21 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto convertViewModel.setSelectedAsset(args.asset) } + /** + * Shows the route-provider line ("using network") under the receive amount. + * Hidden when there's no amount, or when the selected asset's route isn't a single known + * provider (mirrors the currency picker's route label). + */ + private fun updateReceiveNetwork(visible: Boolean) { + val routeResId = mayaViewModel.getRouteLabelResId(args.asset) + if (visible && routeResId != null) { + binding.usingNetwork.text = getString(R.string.maya_receive_using_network, getString(routeResId)) + binding.usingNetwork.isVisible = true + } else { + binding.usingNetwork.isVisible = false + } + } + private fun proceedWithSwap(request: SwapRequest, checkSendingConditions: Boolean = true) { if (request.cryptoAmount == null && request.amount != null) { showSwapValueErrorView(SwapValueErrorType.ExchangeRateMissing) @@ -406,7 +430,7 @@ class MayaConvertCryptoFragment : Fragment(R.layout.fragment_maya_convert_crypto val accountData = it.coinbaseAccount val currency = accountData.currency.lowercase() val iconUrl = if (accountData.currency.isNotEmpty()) { - GenericUtils.getCoinIcon(currency) + GenericUtils.getCoinIcon(currency, accountData.asset) } else { null } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt index 936f33da7b..4237d7650c 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoViewModel.kt @@ -58,6 +58,8 @@ class MayaConvertCryptoViewModel @Inject constructor( networkState: NetworkStateInt, private val analyticsService: AnalyticsService ) : ViewModel() { + val networkParameters get() = walletDataProvider.networkParameters + var paymentIntent: PaymentIntent? = null private val _showLoading: MutableLiveData = MutableLiveData() val showLoading: LiveData @@ -94,7 +96,7 @@ class MayaConvertCryptoViewModel @Inject constructor( target_maya_asset = swapTradeInfo.cryptoCurrencyAsset, fiatCurrency = swapTradeInfo.fiatCurrencyCode, targetAddress = swapTradeInfo.destinationAddress, - maximum = swapTradeInfo.maximum, + maximum = swapTradeInfo.maximum ) when (val result = swapProvider.getSwapInfo(swapRequest)) { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerFragment.kt index 02ec49cfca..b89d7894c0 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerFragment.kt @@ -18,195 +18,42 @@ package org.dash.wallet.integrations.maya.ui import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat -import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.DiffUtil import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import org.dash.wallet.common.ui.components.Toast -import org.dash.wallet.common.ui.components.ToastImageResource -import org.dash.wallet.common.ui.decorators.ListDividerDecorator import org.dash.wallet.common.ui.dialogs.AdaptiveDialog -import org.dash.wallet.common.ui.radio_group.IconSelectMode -import org.dash.wallet.common.ui.radio_group.IconifiedViewItem -import org.dash.wallet.common.ui.recyclerview.IconifiedListAdapter -import org.dash.wallet.common.ui.viewBinding -import org.dash.wallet.common.util.GenericUtils -import org.dash.wallet.common.util.observe import org.dash.wallet.common.util.safeNavigate import org.dash.wallet.integrations.maya.R -import org.dash.wallet.integrations.maya.databinding.FragmentCurrencyPickerBinding import org.dash.wallet.integrations.maya.model.PoolInfo -import org.dash.wallet.integrations.maya.payments.MayaCurrencyList import org.slf4j.LoggerFactory @AndroidEntryPoint -class MayaCryptoCurrencyPickerFragment : Fragment(R.layout.fragment_currency_picker) { +class MayaCryptoCurrencyPickerFragment : Fragment() { companion object { private val log = LoggerFactory.getLogger(MayaCryptoCurrencyPickerFragment::class.java) } - private val binding by viewBinding(FragmentCurrencyPickerBinding::bind) - private val viewModel by mayaViewModels() - private var itemList = listOf() - private lateinit var defaultItemMap: Map - - class FullDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: IconifiedViewItem, newItem: IconifiedViewItem): Boolean { - return oldItem.id != null && oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: IconifiedViewItem, newItem: IconifiedViewItem): Boolean { - return oldItem == newItem - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.toolbar.setNavigationOnClickListener { - findNavController().popBackStack() - } - - val adapter = IconifiedListAdapter(diffCallback = FullDiffCallback()) { item, _ -> - viewModel.poolList.value.firstOrNull { - it.asset == item.id - }?.let { - val inboundAddress = viewModel.getInboundAddress(it.asset) - // if trading is halted, then don't perform an action - if (inboundAddress != null && !inboundAddress.halted) { - clickListener(it) - } - } - } - - val divider = ContextCompat.getDrawable(requireContext(), org.dash.wallet.common.R.drawable.list_divider)!! - val decorator = ListDividerDecorator( - divider, - showAfterLast = false, - marginStart = resources.getDimensionPixelOffset(org.dash.wallet.common.R.dimen.divider_margin_horizontal), - marginEnd = resources.getDimensionPixelOffset(org.dash.wallet.common.R.dimen.divider_margin_horizontal) - ) - binding.contentList.addItemDecoration(decorator) - binding.contentList.adapter = adapter - - // using this allows for translation of cryptocurrency names - defaultItemMap = MayaCurrencyList.all.associateBy({ it.asset }, { - IconifiedViewItem( - requireContext().getString(it.codeId), - requireContext().getString(it.nameId) - ) - }) - binding.searchQuery.doAfterTextChanged { text -> - lifecycleScope.launch { - if (!text.isNullOrEmpty()) { - val fromQuery = itemList.filter { - it.title.contains(text.toString().uppercase()) || it.subtitle.uppercase() - .contains(text.toString().uppercase()) - } - adapter.submitList(fromQuery) - } else { - adapter.submitList(itemList) - } - } - } + private val viewModel by mayaViewModels() - combine(viewModel.poolList, viewModel.inboundAddresses) { pools, addresses -> - pools.filter { pool -> pool.asset != "DASH.DASH" } - .filter { pool -> - defaultItemMap.containsKey(pool.asset) && pool.status.equals( - "available", - ignoreCase = true - ) - } - .filter { pool -> addresses.any { pool.asset.startsWith(it.chain) } } - .map { pool -> - val chain = pool.asset.substringBefore('.') - val inbound = addresses.find { it.chain == chain } - val isEnabled = inbound != null && !inbound.halted - val price = if (isEnabled) { - GenericUtils.formatFiatWithoutComma( - viewModel.formatFiat(pool.assetPriceFiat) - ) - } else { - null - } - val haltedLabel = if (inbound?.halted == true) getString(R.string.maya_halted_label) else null - if (defaultItemMap.containsKey(pool.asset)) { - defaultItemMap[pool.asset]!!.copy( - iconUrl = GenericUtils.getCoinIcon(pool.currencyCode), - iconSelectMode = IconSelectMode.None, - additionalInfo = price, - actionText = haltedLabel, - actionBackgroundColor = if (inbound?.halted == true) R.color.gray_100 else null, - actionTextColor = if (inbound?.halted == true) R.color.content_secondary else null, - isEnabled = isEnabled, - id = pool.asset - ) - } else { - IconifiedViewItem( - pool.currencyCode, - pool.asset, - iconUrl = GenericUtils.getCoinIcon(pool.currencyCode), - iconSelectMode = IconSelectMode.None, - additionalInfo = price, - actionText = haltedLabel, - actionBackgroundColor = if (inbound?.halted == true) R.color.gray_100 else null, - actionTextColor = if (inbound?.halted == true) R.color.content_secondary else null, - isEnabled = isEnabled, - id = pool.asset - ) - } - }.sortedBy { it.title } - }.observe(viewLifecycleOwner) { items -> - itemList = items - log.info("exchange rate: updating itemList with {}", itemList.firstOrNull()?.additionalInfo) - val currentQuery = binding.searchQuery.text?.toString() ?: "" - if (currentQuery.isNotEmpty()) { - adapter.submitList( - itemList.filter { - it.title.contains(currentQuery.uppercase()) || - it.subtitle.uppercase().contains(currentQuery.uppercase()) - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MayaCryptoCurrencyPickerScreen( + viewModel = viewModel, + onBackClick = { findNavController().popBackStack() }, + onCoinClick = ::onCoinSelected, + onShowError = ::showErrorAlert ) - } else { - adapter.submitList(itemList) - } - } - - viewModel.uiState.observe(viewLifecycleOwner) { uiState -> - uiState.errorCode?.let { - showErrorAlert(it) - } - } - - binding.haltedCoinsToast.setViewCompositionStrategy( - ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed - ) - binding.haltedCoinsToast.setContent { - val hasHaltedCoins by viewModel.hasHaltedCoins.collectAsStateWithLifecycle() - var dismissed by remember { mutableStateOf(false) } - - if (hasHaltedCoins && !dismissed) { - Toast( - text = stringResource(R.string.maya_halted_coins_toast), - actionText = stringResource(android.R.string.ok), - imageResource = ToastImageResource.Warning.resourceId - ) { - dismissed = true - } } } } @@ -227,7 +74,19 @@ class MayaCryptoCurrencyPickerFragment : Fragment(R.layout.fragment_currency_pic } } + private fun onCoinSelected(asset: String) { + // Defense-in-depth: re-check halt status before navigating. The Compose row + // already disables clicks for halted/unavailable coins, but the asset can + // transition to halted between render and tap. + val pool = viewModel.poolList.value.firstOrNull { it.asset == asset } ?: return + val inboundAddress = viewModel.getInboundAddress(pool.asset) + if (inboundAddress != null && !inboundAddress.halted && !pool.mayaHalted) { + clickListener(pool) + } + } + private fun clickListener(pool: PoolInfo) { + log.info("currency picker: navigating to address input for {}", pool.asset) safeNavigate( MayaCryptoCurrencyPickerFragmentDirections.mayaCurrencyPickerToAddressInput( pool.currencyCode, diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerScreen.kt new file mode 100644 index 0000000000..4542e3ef4e --- /dev/null +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaCryptoCurrencyPickerScreen.kt @@ -0,0 +1,416 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.integrations.maya.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import org.dash.wallet.common.ui.components.CoinSelect +import org.dash.wallet.common.ui.components.CoinSelectState +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.NavBarBackTitle +import org.dash.wallet.common.ui.components.SearchField +import org.dash.wallet.common.ui.components.Toast +import org.dash.wallet.common.ui.components.ToastImageResource +import org.dash.wallet.integrations.maya.R + +// Mirrors the common DashList card styling (its shape/shadow tokens are private). +// Replicated here so the coin list can be a lazy LazyColumn while keeping the look. +private val CardShape = RoundedCornerShape(20.dp) +private val CardShadowColor = Color(0xFFB8C1CC).copy(alpha = 0.10f) + +@Composable +fun MayaCryptoCurrencyPickerScreen( + viewModel: MayaViewModel, + onBackClick: () -> Unit, + onCoinClick: (String) -> Unit, + onShowError: (Int) -> Unit +) { + val uiState by viewModel.currencyPickerUIState.collectAsStateWithLifecycle() + val portalState by viewModel.uiState.collectAsStateWithLifecycle() + val hasHaltedCoins by viewModel.hasHaltedCoins.collectAsStateWithLifecycle() + + LaunchedEffect(portalState.errorCode) { + portalState.errorCode?.let(onShowError) + } + + MayaCryptoCurrencyPickerScreenContent( + items = uiState.coins, + isLoading = uiState.isLoading, + isOnline = uiState.isOnline, + hasHaltedCoins = hasHaltedCoins, + searchQuery = uiState.searchQuery, + onSearchChange = viewModel::onSearchQuery, + onCoinClick = onCoinClick, + onBackClick = onBackClick + ) +} + +@Composable +private fun MayaCryptoCurrencyPickerScreenContent( + items: List, + isLoading: Boolean, + isOnline: Boolean, + hasHaltedCoins: Boolean, + searchQuery: String, + onSearchChange: (String) -> Unit, + onCoinClick: (String) -> Unit, + onBackClick: () -> Unit +) { + var haltedDismissed by remember { mutableStateOf(false) } + var networkDismissed by remember { mutableStateOf(false) } + + // Offline: hide the search bar and show the "no connection" Toast at the bottom + // (per design). With no cached coins the list area shows a centered "No available + // coins" message; with a cache it shows the list with every row disabled. + val showSearch = isOnline + + // Filter here (not in the ViewModel) so we can match the localized coin name in + // addition to the code/asset, matching the legacy fragment. stringResource is only + // available in composition; the list is small so per-recomposition filtering is fine. + val query = searchQuery.trim().uppercase() + val displayItems: List = if (query.isEmpty()) { + items + } else { + val matches = mutableListOf() + for (coin in items) { + val name = if (coin.nameId != 0) stringResource(coin.nameId) else coin.currencyCode + val code = if (coin.codeId != 0) stringResource(coin.codeId) else coin.asset + if (name.uppercase().contains(query) || + code.uppercase().contains(query) || + coin.asset.uppercase().contains(query) + ) { + matches.add(coin) + } + } + matches + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MyTheme.Colors.backgroundPrimary) + ) { + Column(modifier = Modifier.fillMaxSize()) { + NavBarBackTitle( + title = stringResource(R.string.maya_select_coin_title), + onBackClick = onBackClick + ) + + if (showSearch) { + SearchField( + query = searchQuery, + onQueryChange = onSearchChange, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp) + ) + } + + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + CircularProgressIndicator( + color = MyTheme.Colors.dashBlue, + strokeWidth = 3.dp, + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + ) + } + } + + items.isEmpty() -> { + // Empty list area (offline with no cache, or a genuinely empty list): + // a centered "No available coins" message, per design. + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = stringResource(R.string.maya_no_available_coins), + style = MyTheme.Typography.TitleSmall, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.align(Alignment.Center) + ) + } + } + + else -> { + // Lazy list inside the rounded white "DashList" card. Each coin is its + // own LazyColumn item (rather than one item wrapping a forEach) so only + // visible rows compose on the first frame — this is what lets the screen + // appear immediately instead of stalling on the full list. fill = false + // makes the card wrap its content for short/filtered lists and cap at the + // available height (scrolling) when long. + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .padding(horizontal = 20.dp) + .shadow( + elevation = 5.dp, + shape = CardShape, + ambientColor = CardShadowColor, + spotColor = CardShadowColor + ) + .clip(CardShape) + .background(MyTheme.Colors.backgroundSecondary) + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(bottom = 4.dp) + ) { + items(displayItems, key = { it.asset }) { coin -> + CoinRow(item = coin, onCoinClick = onCoinClick) + } + } + } + } + } + + // No-connection toast, pinned at the bottom (per design): no-wifi icon, message, + // and a dismiss button. Shown whenever offline, over both the empty and list states. + if (!isOnline && !networkDismissed) { + Toast( + text = stringResource(R.string.maya_no_connection_toast), + imageResource = ToastImageResource.NoInternet.resourceId, + showDismissButton = true, + onDismiss = { networkDismissed = true }, + onActionClick = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 25.dp, vertical = 8.dp) + ) + } + + // The halted-coins toast is about per-asset trading halts, not connectivity; + // suppress it while offline so it doesn't compete with the network toast. + if (hasHaltedCoins && !haltedDismissed && isOnline) { + Toast( + text = stringResource(R.string.maya_halted_coins_toast), + actionText = stringResource(android.R.string.ok), + imageResource = ToastImageResource.Warning.resourceId, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 5.dp, vertical = 8.dp) + ) { + haltedDismissed = true + } + } + } +} + +@Composable +private fun CoinRow( + item: CoinPickerItem, + onCoinClick: (String) -> Unit +) { + val state = when { + item.isHalted -> CoinSelectState.HaltedChain + !item.isEnabled -> CoinSelectState.Disabled + else -> CoinSelectState.Active + } + // Single-provider assets show the network statically; both-provider assets show + // "Multiple networks" until the background quote resolves a preferred network, then + // show it with a trailing "*" to mark it as calculated. + val network = item.routeLabelId?.let { labelId -> + stringResource(labelId) + if (item.routeCalculated) "*" else "" + } + + CoinSelect( + name = if (item.nameId != 0) stringResource(item.nameId) else item.currencyCode, + symbol = if (item.codeId != 0) stringResource(item.codeId) else item.asset, + coinIcon = { CoinIcon(item.iconUrls) }, + state = state, + price = item.price, + network = network, + haltedLabel = stringResource(R.string.maya_halted_label), + onClick = { onCoinClick(item.asset) } + ) +} + +/** + * Coin icon that tries each candidate URL in [iconUrls] in order, advancing to the + * next source whenever one fails to load. The neutral coin placeholder is shown while + * loading and as the final fallback once every source has failed (or when there are + * no candidates). + */ +@Composable +private fun CoinIcon(iconUrls: List) { + var index by remember(iconUrls) { mutableStateOf(0) } + val placeholder = painterResource(R.drawable.ic_coin_placeholder) + val isLast = index >= iconUrls.lastIndex + AsyncImage( + model = iconUrls.getOrNull(index), + contentDescription = null, + placeholder = placeholder, + // Only surface the placeholder on error once the last source has failed; + // intermediate failures advance to the next URL instead. + error = if (isLast) placeholder else null, + fallback = placeholder, + onError = { if (!isLast) index++ }, + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + ) +} + +// ── Previews ──────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, widthDp = 393, heightDp = 760) +@Composable +private fun MayaCryptoCurrencyPickerScreenPreview() { + val sample = listOf( + CoinPickerItem( + asset = "BTC.BTC", + currencyCode = "BTC", + nameId = 0, + codeId = 0, + iconUrls = emptyList(), + price = "$64,000.00", + // Single-provider (Maya-only) → static label, no asterisk. + routeLabelId = R.string.maya_route_label_maya, + routeCalculated = false, + isHalted = false, + isEnabled = true + ), + CoinPickerItem( + asset = "NEAR.NEAR", + currencyCode = "NEAR", + nameId = 0, + codeId = 0, + iconUrls = emptyList(), + price = "$5.20", + routeLabelId = R.string.maya_route_label_near, + routeCalculated = false, + isHalted = false, + isEnabled = true + ), + CoinPickerItem( + asset = "ETH.ETH", + currencyCode = "ETH", + nameId = 0, + codeId = 0, + iconUrls = emptyList(), + price = "$3,100.00", + // Both providers, preferred network resolved by quote → "Maya*". + routeLabelId = R.string.maya_route_label_maya, + routeCalculated = true, + isHalted = false, + isEnabled = true + ), + CoinPickerItem( + asset = "UNI.UNI", + currencyCode = "UNI", + nameId = 0, + codeId = 0, + iconUrls = emptyList(), + price = "$8.40", + // Both providers, still resolving → "Multiple networks". + routeLabelId = R.string.maya_route_label_multiple, + routeCalculated = false, + isHalted = false, + isEnabled = true + ), + CoinPickerItem( + asset = "USDT.USDT", + currencyCode = "USDT", + nameId = 0, + codeId = 0, + iconUrls = emptyList(), + price = null, + routeLabelId = R.string.maya_route_label_maya, + routeCalculated = false, + isHalted = true, + isEnabled = false + ) + ) + MayaCryptoCurrencyPickerScreenContent( + items = sample, + isLoading = false, + isOnline = true, + hasHaltedCoins = true, + searchQuery = "", + onSearchChange = {}, + onCoinClick = {}, + onBackClick = {} + ) +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 760) +@Composable +private fun MayaCryptoCurrencyPickerScreenLoadingPreview() { + MayaCryptoCurrencyPickerScreenContent( + items = emptyList(), + isLoading = true, + isOnline = true, + hasHaltedCoins = false, + searchQuery = "", + onSearchChange = {}, + onCoinClick = {}, + onBackClick = {} + ) +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 760) +@Composable +private fun MayaCryptoCurrencyPickerScreenOfflinePreview() { + // Offline: no search bar, "No available coins" empty state, and the no-connection toast. + MayaCryptoCurrencyPickerScreenContent( + items = emptyList(), + isLoading = false, + isOnline = false, + hasHaltedCoins = false, + searchQuery = "", + onSearchChange = {}, + onCoinClick = {}, + onBackClick = {} + ) +} diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt index 6c82cd40ae..e526adbb4a 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.bitcoinj.core.Coin import org.bitcoinj.utils.Fiat import org.bitcoinj.utils.MonetaryFormat import org.dash.wallet.common.Configuration @@ -32,22 +31,23 @@ import org.dash.wallet.common.data.SingleLiveEvent import org.dash.wallet.common.data.WalletUIConfig import org.dash.wallet.common.data.entity.ExchangeRate import org.dash.wallet.common.services.ExchangeRatesProvider +import org.dash.wallet.common.services.NetworkStateInt import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.common.util.Constants import org.dash.wallet.common.util.GenericUtils import org.dash.wallet.common.util.isCurrencyFirst import org.dash.wallet.common.util.toBigDecimal import org.dash.wallet.common.util.toFiat +import org.dash.wallet.integrations.maya.R import org.dash.wallet.integrations.maya.api.DispatchingSwapProvider import org.dash.wallet.integrations.maya.api.FiatExchangeRateProvider -import org.dash.wallet.integrations.maya.api.MayaApi import org.dash.wallet.integrations.maya.api.MayaApiAggregator +import org.dash.wallet.integrations.maya.api.RouteProvider import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.InboundAddress import org.dash.wallet.integrations.maya.model.PoolInfo import org.dash.wallet.integrations.maya.payments.MayaCurrencyList import org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator -import org.dash.wallet.integrations.maya.swapkit.SwapKitConstants import org.dash.wallet.integrations.maya.utils.MayaConfig import org.dash.wallet.integrations.maya.utils.SwapBackend import org.slf4j.Logger @@ -61,6 +61,41 @@ data class MayaPortalUIState( val errorCode: Int? = null ) +/** + * Row model for the "Select coin" picker. Context-free: name/code are kept as + * string resource IDs ([nameId]/[codeId]) and resolved with stringResource in the + * row composable so the ViewModel stays free of Android Context. + */ +data class CoinPickerItem( + val asset: String, + val currencyCode: String, + @androidx.annotation.StringRes val nameId: Int, + @androidx.annotation.StringRes val codeId: Int, + // Ordered icon URLs to try in sequence until one loads (see GenericUtils.getCoinIconUrls). + val iconUrls: List, + val price: String?, + // Route-provider label string res: maya / near (single provider), or + // "Multiple networks" while a both-provider asset's preferred network is still + // being resolved. + @androidx.annotation.StringRes val routeLabelId: Int?, + // True when [routeLabelId] is the asynchronously-calculated preferred network for + // a both-provider asset (rendered with a trailing "*"); false for statically-known + // single-provider labels and the "Multiple networks" placeholder. + val routeCalculated: Boolean, + val isHalted: Boolean, + val isEnabled: Boolean +) + +data class CurrencyPickerUIState( + val coins: List = emptyList(), + val searchQuery: String = "", + val isLoading: Boolean = true, + // False when the device has no internet. The picker still renders the cached coin + // list (if any) with every row disabled and a "no connection" graphic; with no cache + // it shows the full-screen graphic and hides the search bar. + val isOnline: Boolean = true +) + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class MayaViewModel @Inject constructor( @@ -70,7 +105,8 @@ class MayaViewModel @Inject constructor( private val fiatExchangeRateProvider: FiatExchangeRateProvider, exchangeRatesProvider: ExchangeRatesProvider, val analytics: AnalyticsService, - walletUIConfig: WalletUIConfig + walletUIConfig: WalletUIConfig, + networkState: NetworkStateInt ) : ViewModel() { companion object { private val log: Logger = LoggerFactory.getLogger(MayaViewModel::class.java) @@ -83,7 +119,7 @@ class MayaViewModel @Inject constructor( val networkError = SingleLiveEvent() - //private var dashExchangeRate: org.bitcoinj.utils.ExchangeRate? = null + // private var dashExchangeRate: org.bitcoinj.utils.ExchangeRate? = null private var fiatExchangeRate: Fiat? = null private val _uiState = MutableStateFlow(MayaPortalUIState()) @@ -105,11 +141,124 @@ class MayaViewModel @Inject constructor( val inboundAddresses: StateFlow> = _inboundAddresses.asStateFlow() private val _exchangeRates = MutableStateFlow>(listOf()) val exchangeRates = _exchangeRates.asStateFlow() - val hasHaltedCoins: StateFlow = inboundAddresses.map { addresses -> - addresses.any { it.halted } + + // Halted when either the per-chain inbound list reports a halt (native Maya + // backend) OR any Maya-only pool is flagged halted (SwapKit backend, where the + // signal is carried per-asset on PoolInfo — see SwapKitApiAggregator.markMayaInfo). + val hasHaltedCoins: StateFlow = combine(inboundAddresses, poolList) { addresses, pools -> + addresses.any { it.halted } || pools.any { it.mayaHalted } }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val paymentParsers = MayaCurrencyList.getPaymentProcessors() + private val _searchQuery = MutableStateFlow("") + + // Membership map: which assets are part of the curated MayaCurrencyList, with + // their translatable name/code resource IDs. Replaces the old defaultItemMap. + private val currencyResIds: Map> = + MayaCurrencyList.all.associateBy({ it.asset }, { it.nameId to it.codeId }) + + /** + * Single UIState for the "Select coin" picker. Builds the row list from the + * pool list + inbound addresses (same rules as the legacy fragment), then + * applies the search filter. Context-free — name/code stay as resource IDs. + */ + val currencyPickerUIState: StateFlow = + combine( + poolList, + inboundAddresses, + _searchQuery, + swapProvider.preferredRouteProviders, + networkState.isConnected + ) { pools, addresses, query, preferredRoutes, isOnline -> + // Offline: show no coins at all (the screen renders the "No available coins" + // empty state + the no-connection toast). We don't surface the cached pool + // list, since it can't be traded without a live connection. + val coins = if (!isOnline) { + emptyList() + } else { + pools.filter { pool -> pool.asset != "DASH.DASH" } + .filter { pool -> + currencyResIds.containsKey(pool.asset) && + pool.status.equals("available", ignoreCase = true) + } + .filter { pool -> addresses.any { pool.asset.startsWith(it.chain) } } + .map { pool -> + val chain = pool.asset.substringBefore('.') + val inbound = addresses.find { it.chain == chain } + // Maya-only assets carry halt status per-asset (pool.mayaHalted), + // OR-ed with the per-chain inbound halt used by the native Maya backend. + val isHalted = inbound?.halted == true || pool.mayaHalted + val isEnabled = inbound != null && !isHalted + val price = if (isEnabled) { + GenericUtils.formatFiatWithoutComma(formatFiat(pool.assetPriceFiat)) + } else { + null + } + val resIds = currencyResIds[pool.asset] + // Single-provider assets are labelled statically from the token-list + // classification. Both-provider assets show "Multiple networks" until + // the background quote resolves a preferred network, then show it with + // a trailing "*" (routeCalculated) to flag it as calculated. + val preferred = preferredRoutes[pool.asset] + val routeLabelId: Int + val routeCalculated: Boolean + when { + pool.mayaOnly -> { + routeLabelId = R.string.maya_route_label_maya + routeCalculated = false + } + pool.nearOnly -> { + routeLabelId = R.string.maya_route_label_near + routeCalculated = false + } + preferred == RouteProvider.MAYA -> { + routeLabelId = R.string.maya_route_label_maya + routeCalculated = true + } + preferred == RouteProvider.NEAR -> { + routeLabelId = R.string.maya_route_label_near + routeCalculated = true + } + else -> { + routeLabelId = R.string.maya_route_label_multiple + routeCalculated = false + } + } + CoinPickerItem( + asset = pool.asset, + currencyCode = pool.currencyCode, + nameId = resIds?.first ?: 0, + codeId = resIds?.second ?: 0, + iconUrls = GenericUtils.getCoinIconUrls(pool.currencyCode, pool.asset), + price = price, + routeLabelId = routeLabelId, + routeCalculated = routeCalculated, + isHalted = isHalted, + isEnabled = isEnabled + ) + } + .sortedBy { it.currencyCode } + } + + // The list is emitted unfiltered; the search filter is applied in the + // composable layer so it can match the localized coin name (resolved via + // stringResource from nameId), preserving the legacy fragment's behavior + // of matching both code and translated name. The ViewModel stays + // Context-free and cannot resolve those localized strings here. + CurrencyPickerUIState( + coins = coins, + searchQuery = query, + // Only spin while we're online and still waiting for the first pool list. + // Offline shows the "No available coins" empty state instead of spinning forever. + isLoading = pools.isEmpty() && isOnline, + isOnline = isOnline + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, CurrencyPickerUIState()) + + fun onSearchQuery(text: String) { + _searchQuery.value = text + } + init { // TODO: is this really needed? we don't support DASH swaps exchangeRatesProvider.observeExchangeRates() @@ -130,7 +279,6 @@ class MayaViewModel @Inject constructor( } .launchIn(viewModelScope) - walletUIConfig.observe(WalletUIConfig.SELECTED_CURRENCY) .filterNotNull() .onEach { log.info("exchange rate selected currency: {}", it) } @@ -244,6 +392,25 @@ class MayaViewModel @Inject constructor( return null } + /** + * Route-provider label string res for [asset] (`maya` / `near`) when it routes through + * a single, known provider, or null when undetermined or routable via both. Mirrors the + * currency picker's classification (pool [PoolInfo.mayaOnly]/[PoolInfo.nearOnly] + the + * asynchronously-resolved [SwapProvider.preferredRouteProviders]). + */ + @androidx.annotation.StringRes + fun getRouteLabelResId(asset: String): Int? { + val pool = poolList.value.find { it.asset == asset } + val preferred = swapProvider.preferredRouteProviders.value[asset] + return when { + pool?.mayaOnly == true -> R.string.maya_route_label_maya + pool?.nearOnly == true -> R.string.maya_route_label_near + preferred == RouteProvider.MAYA -> R.string.maya_route_label_maya + preferred == RouteProvider.NEAR -> R.string.maya_route_label_near + else -> null + } + } + private fun updateInboundAddresses() { viewModelScope.launch(Dispatchers.IO) { refreshInboundAddresses() @@ -272,7 +439,7 @@ class MayaViewModel @Inject constructor( } } - is SwapKitConstants -> { + is SwapKitApiAggregator -> { inboundAddresses.value.isNotEmpty() } is DispatchingSwapProvider -> { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewFragment.kt index 041acd48fd..e0082c334b 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewFragment.kt @@ -72,6 +72,12 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { DecimalFormatSymbols.getInstance(GenericUtils.getDeviceLocale()).decimalSeparator private var maxAmountSelected: Boolean = false private var hasInternet: Boolean = true + + // While a swap quote is being fetched we block all amount input so the value + // shown on the preview screen can't drift from what was submitted. + private var isProcessing: Boolean = false + private var canContinue: Boolean = false + private var continueButtonText: CharSequence = "" private var pickedCurrencyIndex by mutableIntStateOf(0) private var currencyConversionOptions by mutableStateOf(listOf()) private val pickedCurrencyOption: String @@ -105,6 +111,7 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { pickedCurrencyIndex = 0 binding.maxButton.setOnClickListener { + if (isProcessing) return@setOnClickListener viewModel.selectedCryptoCurrencyAccount.value?.let { userAccountData -> viewModel.getMaxAmount()?.let { maxAmount -> val cryptoCurrency = userAccountData.coinbaseAccount.currency @@ -147,6 +154,7 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { shadowElevation = 0 ) ) { value, index -> + if (isProcessing) return@SegmentedPicker pickedCurrencyIndex = index setAmountValue(value.title) viewModel.selectedPickerCurrencyCode = value.title @@ -197,6 +205,7 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { } fun setViewDetails(continueText: String, keyboardHeader: View?) { + continueButtonText = continueText lifecycleScope.launchWhenStarted { binding.continueBtn.text = continueText keyboardHeader?.let { @@ -205,6 +214,23 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { } } + /** + * Blocks/unblocks all amount input while a swap quote is being fetched. Disables + * the keypad, the max button and the currency picker, and replaces the Continue + * button label with a spinner so the submitted amount can't be changed mid-request. + */ + fun setProcessing(processing: Boolean) { + isProcessing = processing + // Detaching the listener (rather than hiding the keys) keeps the keypad on + // screen but inert; the dim signals it's temporarily disabled. + binding.keyboardView.onKeyboardActionListener = if (processing) null else keyboardActionListener + binding.keyboardView.alpha = if (processing) 0.4f else 1.0f + binding.maxButton.isEnabled = !processing + binding.continueProgress.isVisible = processing + binding.continueBtn.text = if (processing) "" else continueButtonText + binding.continueBtn.isEnabled = canContinue && !processing + } + private val keyboardActionListener = object : NumericKeyboardView.OnKeyboardActionListener { var value = StringBuilder() @@ -385,7 +411,8 @@ class ConvertViewFragment : Fragment(R.layout.fragment_convert_currency_view) { } private fun checkTheUserEnteredValue(hasBalance: Boolean) { - binding.continueBtn.isEnabled = hasBalance + canContinue = hasBalance + binding.continueBtn.isEnabled = hasBalance && !isProcessing } fun handleNetworkState(hasInternet: Boolean) { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt index d978159319..2cb173623f 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/MayaConfig.kt @@ -46,5 +46,14 @@ open class MayaConfig @Inject constructor( * `SwapProvider` provider; changes take effect on the next launch. */ val SWAP_BACKEND = stringPreferencesKey("swap_backend") + + /** + * Cached SwapKit coin-list snapshot (JSON): the last published pool list + + * Maya/NEAR classification + preferred-route map + timestamps. Hydrated on cold + * start so the currency picker renders instantly (stale-while-revalidate); the + * aggregator refreshes in the background and overwrites it. See + * [org.dash.wallet.integrations.maya.swapkit.SwapKitApiAggregator]. + */ + val SWAPKIT_POOL_SNAPSHOT = stringPreferencesKey("swapkit_pool_snapshot") } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt index ba6b2fd4b7..a664744e6f 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/utils/SwapBackend.kt @@ -26,4 +26,4 @@ package org.dash.wallet.integrations.maya.utils enum class SwapBackend { MAYA, SWAPKIT -} \ No newline at end of file +} diff --git a/integrations/maya/src/main/res/drawable/ic_coin_placeholder.xml b/integrations/maya/src/main/res/drawable/ic_coin_placeholder.xml new file mode 100644 index 0000000000..7867fa4409 --- /dev/null +++ b/integrations/maya/src/main/res/drawable/ic_coin_placeholder.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml b/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml index f83b2b8197..387d95085e 100644 --- a/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml +++ b/integrations/maya/src/main/res/layout/content_conversion_preview_maya.xml @@ -237,6 +237,47 @@ android:background="@color/divider_color" app:layout_constraintBottom_toBottomOf="@+id/maya_fee_info_container" /> + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/network_info_container" /> -