diff --git a/mobile-app/assets/v2/swap_address_book.svg b/mobile-app/assets/v2/swap_address_book.svg
new file mode 100644
index 000000000..18383ead0
--- /dev/null
+++ b/mobile-app/assets/v2/swap_address_book.svg
@@ -0,0 +1,5 @@
+
diff --git a/mobile-app/assets/v2/swap_arrow_down.svg b/mobile-app/assets/v2/swap_arrow_down.svg
new file mode 100644
index 000000000..8f7ecf1a8
--- /dev/null
+++ b/mobile-app/assets/v2/swap_arrow_down.svg
@@ -0,0 +1,5 @@
+
diff --git a/mobile-app/assets/v2/swap_clock_counter_clockwise.svg b/mobile-app/assets/v2/swap_clock_counter_clockwise.svg
deleted file mode 100644
index b1c5d03ea..000000000
--- a/mobile-app/assets/v2/swap_clock_counter_clockwise.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/mobile-app/assets/v2/swap_list.svg b/mobile-app/assets/v2/swap_list.svg
new file mode 100644
index 000000000..c638e943c
--- /dev/null
+++ b/mobile-app/assets/v2/swap_list.svg
@@ -0,0 +1,5 @@
+
diff --git a/mobile-app/assets/v2/swap_pencil.svg b/mobile-app/assets/v2/swap_pencil.svg
new file mode 100644
index 000000000..7daf86b4c
--- /dev/null
+++ b/mobile-app/assets/v2/swap_pencil.svg
@@ -0,0 +1,5 @@
+
diff --git a/mobile-app/assets/v2/token_qtc.svg b/mobile-app/assets/v2/token_qtc.svg
new file mode 100644
index 000000000..980f1566f
--- /dev/null
+++ b/mobile-app/assets/v2/token_qtc.svg
@@ -0,0 +1,9 @@
+
diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md
new file mode 100644
index 000000000..9564c5f7e
--- /dev/null
+++ b/mobile-app/docs/near-intents-swaps.md
@@ -0,0 +1,182 @@
+# NEAR Intents swaps
+
+How the wallet swaps between QTC and another chain's token, in either direction, through the NEAR Intents 1Click API, who sends what, and who waits on whom.
+
+The origin chain is where the deposit goes in: the other chain when swapping into QTC, Quantus when swapping out of it. The destination chain is where the payout lands.
+
+Verified against the live API on 2026-09-20. There is no testnet for NEAR Intents.
+
+## What NEAR Intents is
+
+An intent is a signed statement of an outcome: "I give X of asset A on chain P and want at least Y of asset B on chain Q." Solvers, which are market makers, compete to fill it. The fill settles on NEAR inside the Intents Verifier contract, and bridged representations of the assets (`nep141:*.omft.near`) move in and out over NEAR's bridges.
+
+The 1Click API (`https://1click.chaindefuser.com`) is the REST front door to all of that. It runs the price auction, hands out one deposit address per quote, watches the origin chain, drives execution, pays out and refunds. The wallet only talks to 1Click. It never signs or holds any of the swapped funds.
+
+## Actors
+
+| Actor | Where it runs | What it does |
+| --- | --- | --- |
+| User | The Quantus wallet, plus their own wallet on the other chain (MetaMask, Phantom, a BTC wallet...) | Swapping in: types the amount and a refund address, then sends the deposit from the other wallet. Swapping out: types the amount and a recipient address, then confirms. |
+| Quantus wallet | The app, `SwapService` in `quantus_sdk` | Asks 1Click for tokens, quotes and status. Swapping in, the account is the recipient. Swapping out, the account is the refund address and the app signs the QTC deposit itself. |
+| 1Click API | NEAR Intents infrastructure | Auction, deposit addresses, chain watching, execution, payout, refunds, status. |
+| Solvers | Off-chain market makers connected to 1Click | Price the quote and fill the intent. |
+| Intents Verifier contract | NEAR | Settles the fill atomically. |
+| Other chain | ETH, BTC, SOL, BASE... (36 chains listed today) | The origin of a swap in, the destination of a swap out. |
+| Quantus chain | Quantus | The destination of a swap in, the origin of a swap out. Not connected yet, see [Blockers](#blockers). |
+
+## The happy path: swapping into QTC
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as User
+ participant W as Quantus wallet
+ participant C as 1Click API
+ participant S as Solvers
+ participant O as Origin chain
+ participant N as NEAR Intents contract
+ participant D as Quantus chain
+
+ W->>C: GET /v0/tokens
+ C-->>W: asset ids, decimals, USD prices
+ U->>W: amount, origin token, refund address
+ W->>C: POST /v0/quote (dry: true)
+ C->>S: request for quote
+ Note over C,S: 1Click waits up to quoteWaitingTimeMs (3 s)
+ S-->>C: best price
+ C-->>W: amountOut, minAmountOut, timeEstimate
+ U->>W: Confirm
+ W->>C: POST /v0/quote (dry: false)
+ C-->>W: depositAddress, deadline (now + 20 min)
+ Note over W: Deposit screen: QR + address, polls status every 5 s
+ U->>O: sends amountIn to depositAddress from an external wallet
+ loop every 5 s until SUCCESS, REFUNDED or FAILED
+ W->>C: GET /v0/status?depositAddress=...
+ C-->>W: PENDING_DEPOSIT → KNOWN_DEPOSIT_TX → PROCESSING → SUCCESS
+ end
+ O-->>C: deposit confirmed
+ C->>N: publish intent, solver fills, settle
+ N-->>C: settled
+ C->>D: withdraw amountOut to recipient
+ C-->>W: SUCCESS, destination tx hashes
+```
+
+## The happy path: swapping out of QTC
+
+The same quote and status calls with origin and destination swapped. The difference is the deposit: the wallet sends it, so the user never leaves the app.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as User
+ participant W as Quantus wallet
+ participant C as 1Click API
+ participant Q as Quantus chain
+ participant N as NEAR Intents contract
+ participant D as Other chain
+
+ U->>W: amount, destination token, recipient address
+ W->>C: POST /v0/quote (dry: true), refundTo = active account
+ C-->>W: amountOut, minAmountOut
+ W->>Q: payment_queryInfo for the deposit transfer
+ Note over W: Review: rate, recipient, network fee, slippage, guaranteed minimum
+ U->>W: Confirm swap
+ W->>C: POST /v0/quote (dry: false)
+ C-->>W: depositAddress on Quantus, deadline
+ Note over W: live minAmountOut below the reviewed one: show the new terms, confirm again
+ U->>W: device authentication
+ W->>Q: balances transfer of amountIn to depositAddress
+ W->>C: POST /v0/deposit/submit (txHash, depositAddress)
+ loop every 5 s until SUCCESS, REFUNDED or FAILED
+ W->>C: GET /v0/status?depositAddress=...
+ end
+ C->>N: publish intent, solver fills, settle
+ C->>D: withdraw amountOut to the recipient address
+ C-->>W: SUCCESS
+```
+
+The deposit is an ordinary signed transfer from the active account, so only transparent accounts whose key lives in the app can swap. Encrypted, Keystone and watch-only accounts see the swap button disabled.
+
+## Who waits on whom
+
+| Step | Who waits | On what | How long | What ends it |
+| --- | --- | --- | --- | --- |
+| Token list | App | 1Click | Under a second | Response. Cached for 10 minutes in the app. |
+| Dry quote | App | 1Click, which waits on solvers | 1 to 5 s (`quoteWaitingTimeMs` is 3000) | Best solver price, or "Failed to get quote" if no solver bids. |
+| Live quote | App | 1Click | Same as dry | Deposit address is reserved until `deadline`. |
+| Deposit | 1Click, app polls | Swapping in: the user's transaction on the other chain. Swapping out: the app's QTC transfer, handed to 1Click with `/v0/deposit/submit`. | Up to the deadline: 20 minutes in our request, 2 hours from BTC, LTC, DOGE, BCH, DASH and ZEC, whose confirmations take longer than that | Deposit seen, or deadline passes. |
+| Confirmation | 1Click | Origin-chain finality | Seconds on SOL, minutes on ETH, up to an hour on BTC. Docs say allow 15 min. | Enough confirmations. |
+| Settlement | 1Click | Solver fill and NEAR finality | Seconds | Intent settled. |
+| Payout | 1Click | Destination-chain withdrawal | Seconds to minutes | `SUCCESS` with `destinationChainTxHashes`. |
+
+The app never blocks on anything but its own HTTP calls and, swapping out, the QTC transfer submission. The user waits on the progress screen, and it is safe for them to leave it: 1Click continues regardless. Losing the screen does lose the order in the app, see [Blockers](#blockers).
+
+## Who sends what
+
+| Call | App sends | App reads back |
+| --- | --- | --- |
+| `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. The entry whose `assetId` is `AppConstants.quantusIntentsAssetId` is QTC as 1Click lists it and takes over from the app's own QTC metadata; it is refused if its decimals differ from the chain's. |
+| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. Every quote's signature is checked before it is used (`OneClickQuoteSignature`, a port of `verifyQuoteSignature` from the 1Click TypeScript SDK: Ed25519 over the base58 SHA-256 of a key-sorted JSON of the signed request and quote fields plus `timestamp`, against `AppConstants.oneClickManagerPublicKey`), the echoed `quoteRequest` must repeat the amount, assets, addresses, slippage, `dry` flag and deadline that were sent, and `quote.amountIn` must be the amount sent, so the reviewed input is what a swap out transfers. A live quote whose deposit deadline is under `SwapService.minimumDepositLead` (5 minutes) away is refused before anything is sent. Any of these failing is a `SwapQuoteIntegrityException`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. |
+| `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses |
+| `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. |
+
+Amounts are strings of base units in both directions. The wallet parses user input with the origin token's decimals and formats every amount with its own token's decimals.
+
+Errors come back as `{"message": ...}` with a 4xx status. The app shows the message as is. Seen live: `refundTo is not valid`, `tokenOut is not valid`, `Failed to get quote` for an amount too small to fill, and 404 `Deposit address ... not found` on status.
+
+## Swap status
+
+```mermaid
+stateDiagram-v2
+ [*] --> PENDING_DEPOSIT: live quote
+ PENDING_DEPOSIT --> KNOWN_DEPOSIT_TX: deposit tx seen
+ PENDING_DEPOSIT --> INCOMPLETE_DEPOSIT: less than amountIn arrived
+ PENDING_DEPOSIT --> Expired: deadline passes, nothing arrived
+ KNOWN_DEPOSIT_TX --> PROCESSING: confirmed
+ PROCESSING --> SUCCESS: filled and paid out
+ PROCESSING --> REFUNDED: no fill, price moved past slippage, or deadline
+ PROCESSING --> FAILED: error
+ INCOMPLETE_DEPOSIT --> REFUNDED: after deadline
+ SUCCESS --> [*]
+ REFUNDED --> [*]
+ FAILED --> [*]
+```
+
+`Expired` is not a 1Click status. 1Click keeps reporting `PENDING_DEPOSIT` after the deadline; the app shows an expired view when the deadline has passed and nothing arrived, and keeps polling in case a late deposit turns into a refund.
+
+## Refunds
+
+- Less than `amountIn` arrives: refunded to `refundTo` after the deadline, minus `refundFee` (0.30 USDC on ETH in the live probe).
+- More than `amountIn` arrives: the swap executes and the excess goes to `refundTo`.
+- No solver can fill, or the price moves past the slippage: `REFUNDED`.
+- 1Click validates `refundTo` against the origin chain at quote time, so a bad refund address never reaches the deposit step.
+
+## Fees
+
+- Without a partner key 1Click adds its platform fee to every quote: 25 basis points per the docs, 20 in the live probes of 2026-09-26, echoed as `appFees: [{recipient: 5880ad2b..., fee: 20}]` on a request that sent none.
+- With a partner key from partners.near-intents.org the platform fee is 20 basis points, 1 basis point on stablecoin and same-asset routes. Pass it as `SwapService(apiKey:)`; it goes out as `X-API-Key`.
+- Fees are inside `amountOut`. Nothing is charged on top of `amountIn`.
+- `refundFee` and `withdrawFee` in the quote are in base units of the origin asset.
+
+## Where this lives in the wallet
+
+| Screen or class | File | 1Click call |
+| --- | --- | --- |
+| `SwapScreen` | `mobile-app/lib/v2/screens/swap/swap_screen.dart` | Tokens on open. One form for both directions; the arrows flip it. |
+| Address sheet | `mobile-app/lib/v2/screens/swap/swap_address_sheet.dart` | Recipient (out) or refund (in) address, then the dry quote. Saved addresses are per network. |
+| `ReviewSwapScreen` | `mobile-app/lib/v2/screens/swap/review_swap_screen.dart` | Live quote on Confirm, checked against the reviewed minimum. Swapping out, it also signs and sends the deposit and calls `/v0/deposit/submit`. |
+| `SwapProgressScreen` | `mobile-app/lib/v2/screens/swap/swap_progress_screen.dart` | Status every 5 s through `swapOrderProvider`: the deposit address while a swap in waits, then progress steps, complete, or failed and refunded. |
+| `SwapService` | `quantus_sdk/lib/src/services/swap_service.dart` | The HTTP client. `SwapApiException` carries the server message. |
+| `SwapToken`, `SwapQuote`, `SwapOrder` | `quantus_sdk/lib/src/models/swap_*.dart` | Asset ids and BigInt base-unit amounts. |
+| Endpoints | `quantus_sdk/lib/src/constants/app_constants.dart` | `oneClickEndpoint`, `quantusIntentsAssetId`. |
+
+Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, deposit submission, error handling, and signature checks against a mock HTTP client that signs its responses; the 1Click SDK's staging fixtures are verified byte for byte against its staging key. `mobile-app/test/screens/swap_flow_test.dart` drives the form, the swap-out confirmation, the price-moved re-confirmation and the outcome screens.
+
+## Blockers
+
+1. **QTC is not listed on NEAR Intents.** The token list has 197 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote fails with `tokenOut is not valid` swapping in and `tokenIn is not valid` swapping out. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team. Once they list it, set `quantusIntentsAssetId` to the real id: the app then reads QTC's decimals and price from the listing instead of its own metadata. Swap stays behind the `enableSwap` remote config flag until swaps have been tested against the live listing.
+2. **No partner key**, so every quote carries the extra 25 basis points.
+3. **Orders are not persisted.** If the progress screen is closed, the app has no record of the swap. A swap in's deposit address stays in the user's other wallet, a swap out's deposit shows as an ordinary transfer in activity, and 1Click keeps processing both.
+4. The home swap button follows the `enableSwap` remote config flag and is enabled only for transparent accounts that sign in the app.
+5. Only `EXACT_INPUT` with origin-chain deposits and destination-chain payout. Signed-intent execution would need the wallet to sign NEP-413 or similar payloads.
+6. Slippage is picked from four presets (0.5, 1, 2, 3%); there is no custom value, and the pick is not persisted between launches.
diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb
index d033d154e..e14300fb5 100644
--- a/mobile-app/lib/l10n/app_en.arb
+++ b/mobile-app/lib/l10n/app_en.arb
@@ -2302,65 +2302,69 @@
},
"swapFrom": "From",
"@swapFrom": {
- "description": "From token section label on swap screen"
+ "description": "Label of the side the swapped funds come from"
},
"swapTo": "To",
"@swapTo": {
- "description": "To token section label on swap screen"
+ "description": "Label of the side the swapped funds go to"
},
- "swapRefundAddress": "Refund Address",
- "@swapRefundAddress": {
- "description": "Refund address field label on swap screen"
- },
- "swapRefundAddressHint": "{network} Address",
- "@swapRefundAddressHint": {
- "description": "Refund address field hint",
- "placeholders": {
- "network": {
- "type": "String"
- }
- }
- },
- "swapSlippageTolerance": "Slippage Tolerance",
- "@swapSlippageTolerance": {
- "description": "Slippage tolerance label on swap screen"
+ "swapExternalWallet": "A wallet you control",
+ "@swapExternalWallet": {
+ "description": "Owner of the external token side of the swap form"
},
"swapRate": "Rate",
"@swapRate": {
"description": "Exchange rate label on swap screen"
},
- "swapGetQuote": "Get a Quote",
- "@swapGetQuote": {
- "description": "Get quote button on swap screen"
- },
- "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}",
+ "swapRateLabel": "1 {fromSymbol} = {rate} {toSymbol}",
"@swapRateLabel": {
- "description": "Exchange rate display",
+ "description": "Exchange rate: what one input token buys",
"placeholders": {
- "amount": {
+ "fromSymbol": {
"type": "String"
},
- "symbol": {
+ "rate": {
"type": "String"
},
- "tokenSymbol": {
+ "toSymbol": {
"type": "String"
}
}
},
- "swapRateZero": "1 {tokenSymbol} = 0 {symbol}",
- "@swapRateZero": {
- "description": "Exchange rate when amount is zero",
+ "swapSlippageLabel": "Slippage {percent}%",
+ "@swapSlippageLabel": {
+ "description": "Slippage tolerance under the swap form",
"placeholders": {
- "symbol": {
+ "percent": {
"type": "String"
- },
- "tokenSymbol": {
+ }
+ }
+ },
+ "swapSlippageTitle": "Slippage Tolerance",
+ "@swapSlippageTitle": {
+ "description": "Title of the slippage tolerance sheet"
+ },
+ "swapSlippageBody": "If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.",
+ "@swapSlippageBody": {
+ "description": "Explanation on the slippage tolerance sheet"
+ },
+ "swapSlippagePercent": "{percent}%",
+ "@swapSlippagePercent": {
+ "description": "A slippage tolerance option",
+ "placeholders": {
+ "percent": {
"type": "String"
}
}
},
-
+ "swapAddRecipientAddress": "Add Recipient Address",
+ "@swapAddRecipientAddress": {
+ "description": "Swap form button for a swap out of QTC"
+ },
+ "swapAddRefundAddress": "Add Refund Address",
+ "@swapAddRefundAddress": {
+ "description": "Swap form button for a swap into QTC"
+ },
"swapTokenPickerTitle": "Select Token",
"@swapTokenPickerTitle": {
"description": "Title on token picker sheet"
@@ -2369,22 +2373,117 @@
"@swapTokenPickerLoadError": {
"description": "Error when token list fails to load"
},
-
- "swapReviewTitle": "Review Quote",
+ "swapQuoteError": "Quote failed: {message}",
+ "@swapQuoteError": {
+ "description": "Toast when 1Click rejects a quote or a swap cannot be created",
+ "placeholders": {
+ "message": {
+ "type": "String"
+ }
+ }
+ },
+ "swapRecipientAddressTitle": "Recipient Address",
+ "@swapRecipientAddressTitle": {
+ "description": "Title of the sheet asking where the swapped funds go"
+ },
+ "swapRefundAddressTitle": "Refund Address",
+ "@swapRefundAddressTitle": {
+ "description": "Title of the sheet asking where a failed swap is refunded"
+ },
+ "swapAddressLabel": "{symbol} Address",
+ "@swapAddressLabel": {
+ "description": "Address field label in the swap address sheet",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ }
+ }
+ },
+ "swapAddressHint": "{network} Address",
+ "@swapAddressHint": {
+ "description": "Address field hint in the swap address sheet",
+ "placeholders": {
+ "network": {
+ "type": "String"
+ }
+ }
+ },
+ "swapRecipientAddressNotice": "Your {symbol} will arrive at this address. Double-check it, swaps cannot be reversed.",
+ "@swapRecipientAddressNotice": {
+ "description": "Warning under the recipient address field",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ }
+ }
+ },
+ "swapRefundAddressNotice": "If the swap cannot complete, your {symbol} returns to this address on {network}. Double-check it.",
+ "@swapRefundAddressNotice": {
+ "description": "Warning under the refund address field",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ },
+ "network": {
+ "type": "String"
+ }
+ }
+ },
+ "swapSaveAddress": "Save for future swaps",
+ "@swapSaveAddress": {
+ "description": "Checkbox that saves the entered address"
+ },
+ "swapContinue": "Continue to Swap",
+ "@swapContinue": {
+ "description": "Button that quotes the swap with the entered address"
+ },
+ "swapSavedAddressesTitle": "Saved Addresses",
+ "@swapSavedAddressesTitle": {
+ "description": "Title of the saved swap addresses sheet"
+ },
+ "swapSavedAddressesEmpty": "No saved addresses yet",
+ "@swapSavedAddressesEmpty": {
+ "description": "Empty saved swap addresses sheet"
+ },
+ "swapReviewTitle": "Review Swap",
"@swapReviewTitle": {
- "description": "Title on review quote sheet"
+ "description": "Title of the swap review screen"
+ },
+ "swapYouPay": "You pay",
+ "@swapYouPay": {
+ "description": "Pay side of the swap review card"
+ },
+ "swapYouReceive": "You receive",
+ "@swapYouReceive": {
+ "description": "Receive side of the swap review card"
+ },
+ "swapReviewRecipient": "Recipient",
+ "@swapReviewRecipient": {
+ "description": "Review row: external address receiving the swap"
+ },
+ "swapReviewRefundAddress": "Refund address",
+ "@swapReviewRefundAddress": {
+ "description": "Review row: external address a failed swap is refunded to"
+ },
+ "swapReviewNetworkFee": "Network fee",
+ "@swapReviewNetworkFee": {
+ "description": "Review row and details row: chain fee of the QTC deposit"
},
- "swapReviewTotalFees": "Total fees",
- "@swapReviewTotalFees": {
- "description": "Total fees row on review quote sheet"
+ "swapReviewFeeUnavailable": "Unavailable",
+ "@swapReviewFeeUnavailable": {
+ "description": "Network fee value when it could not be fetched"
},
- "swapReviewTotalAmount": "Total Amount",
- "@swapReviewTotalAmount": {
- "description": "Total amount row on review quote sheet"
+ "swapReviewFeeFailed": "Could not get the network fee.",
+ "@swapReviewFeeFailed": {
+ "description": "Error under the review rows when the fee failed"
},
- "swapReviewSlippageWarning": "You could receive up to ${amount} less based on the {percent}% slippage you set",
- "@swapReviewSlippageWarning": {
- "description": "Slippage warning on review quote sheet",
+ "swapReviewSlippage": "Slippage allowance",
+ "@swapReviewSlippage": {
+ "description": "Review row: how far below the quote the output may land"
+ },
+ "swapReviewSlippageValue": "{amount} ({percent}%)",
+ "@swapReviewSlippageValue": {
+ "description": "Slippage allowance value",
"placeholders": {
"amount": {
"type": "String"
@@ -2394,11 +2493,182 @@
}
}
},
- "swapReviewConfirm": "Confirm",
+ "swapReviewGuaranteedMinimum": "Guaranteed minimum",
+ "@swapReviewGuaranteedMinimum": {
+ "description": "Review row: least the swap pays out"
+ },
+ "swapReviewInsufficient": "Not enough {symbol} for this amount plus the network fee.",
+ "@swapReviewInsufficient": {
+ "description": "Review error when the balance cannot cover the deposit",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ }
+ }
+ },
+ "swapReviewPriceMoved": "The price moved. Check the new amounts and confirm again.",
+ "@swapReviewPriceMoved": {
+ "description": "Toast when the live quote guarantees less than the one reviewed"
+ },
+ "swapReviewConfirm": "Confirm swap",
"@swapReviewConfirm": {
- "description": "Confirm button on review quote sheet"
+ "description": "Confirm button on the swap review screen"
+ },
+ "swapInProgressTitle": "Swap In Progress",
+ "@swapInProgressTitle": {
+ "description": "Title while the swap is running"
+ },
+ "swapYourePaying": "You're paying",
+ "@swapYourePaying": {
+ "description": "Pay side of the swap progress card"
+ },
+ "swapYoureReceiving": "You're receiving",
+ "@swapYoureReceiving": {
+ "description": "Receive side of the swap progress card"
+ },
+ "swapStepSent": "{symbol} sent",
+ "@swapStepSent": {
+ "description": "Progress step: deposit sent",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ }
+ }
+ },
+ "swapStepConfirming": "Confirming on {network}",
+ "@swapStepConfirming": {
+ "description": "Progress step: deposit confirming",
+ "placeholders": {
+ "network": {
+ "type": "String"
+ }
+ }
+ },
+ "swapStepSwapping": "Swapping",
+ "@swapStepSwapping": {
+ "description": "Progress step: solvers swapping"
+ },
+ "swapStepSending": "Sending {symbol} to {recipient}",
+ "@swapStepSending": {
+ "description": "Progress step: payout",
+ "placeholders": {
+ "symbol": {
+ "type": "String"
+ },
+ "recipient": {
+ "type": "String"
+ }
+ }
+ },
+ "swapProgressFooter": "Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents",
+ "@swapProgressFooter": {
+ "description": "Footer under the swap progress bar"
+ },
+ "swapDetailsTitle": "Swap Details",
+ "@swapDetailsTitle": {
+ "description": "Title of the swap details sheet"
+ },
+ "swapDetailsRecipient": "Recipient address",
+ "@swapDetailsRecipient": {
+ "description": "Details row: address receiving the swap"
+ },
+ "swapDetailsAmountSent": "Amount sent",
+ "@swapDetailsAmountSent": {
+ "description": "Details row: deposit amount"
+ },
+ "swapDetailsExpected": "Expected",
+ "@swapDetailsExpected": {
+ "description": "Details row: quoted payout"
+ },
+ "swapDetailsTransaction": "Transaction",
+ "@swapDetailsTransaction": {
+ "description": "Details row: deposit transaction hash"
+ },
+ "swapCompleteTitle": "Swap Complete",
+ "@swapCompleteTitle": {
+ "description": "Title when the swap paid out"
+ },
+ "swapCompleteReceived": "Received",
+ "@swapCompleteReceived": {
+ "description": "Caption under the paid-out amount"
+ },
+ "swapCompleteTo": "To:",
+ "@swapCompleteTo": {
+ "description": "Label above where the swap paid out"
+ },
+ "swapFailedTitle": "Swap Failed",
+ "@swapFailedTitle": {
+ "description": "Title when the swap did not pay out"
+ },
+ "swapRefundedLabel": "Refunded",
+ "@swapRefundedLabel": {
+ "description": "Status label on a refunded swap"
+ },
+ "swapYouPaid": "You paid",
+ "@swapYouPaid": {
+ "description": "Pay side of a failed swap card"
+ },
+ "swapYouWereReceiving": "You were receiving",
+ "@swapYouWereReceiving": {
+ "description": "Receive side of a failed swap card"
+ },
+ "swapRefundedBody": "The swap could not be completed, so your {amount} has been returned to {destination}, minus the network fee. The {symbol} was never sent.",
+ "@swapRefundedBody": {
+ "description": "Banner on a refunded swap",
+ "placeholders": {
+ "amount": {
+ "type": "String"
+ },
+ "destination": {
+ "type": "String"
+ },
+ "symbol": {
+ "type": "String"
+ }
+ }
+ },
+ "swapDepositRefundReason": "Reason: {reason}",
+ "@swapDepositRefundReason": {
+ "description": "Refund reason reported by 1Click",
+ "placeholders": {
+ "reason": {
+ "type": "String"
+ }
+ }
+ },
+ "swapDepositFailedBody": "The swap did not complete. Contact support and quote this deposit address: {address}",
+ "@swapDepositFailedBody": {
+ "description": "Body when 1Click reports the swap failed",
+ "placeholders": {
+ "address": {
+ "type": "String"
+ }
+ }
+ },
+ "swapDepositIncompleteTitle": "Deposit Too Small",
+ "@swapDepositIncompleteTitle": {
+ "description": "Title when less than the quoted amount arrived"
+ },
+ "swapDepositIncompleteBody": "Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.",
+ "@swapDepositIncompleteBody": {
+ "description": "Body when less than the quoted amount arrived"
+ },
+ "swapDepositExpiredTitle": "Quote Expired",
+ "@swapDepositExpiredTitle": {
+ "description": "Title when the deadline passed with no deposit"
+ },
+ "swapDepositExpiredBody": "No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.",
+ "@swapDepositExpiredBody": {
+ "description": "Body when the deadline passed with no deposit"
+ },
+ "swapStartNew": "Start a New Swap",
+ "@swapStartNew": {
+ "description": "Button on a failed swap"
+ },
+ "swapContactSupport": "Contact Support",
+ "@swapContactSupport": {
+ "description": "Link on a failed swap"
},
-
"swapDepositAmount": "Deposit Amount",
"@swapDepositAmount": {
"description": "Deposit amount label on deposit screen"
@@ -2407,9 +2677,22 @@
"@swapDepositAmountCopied": {
"description": "Toast when deposit amount is copied"
},
- "swapDepositDemoWarning": "For demo purposes only - do not send funds!",
- "@swapDepositDemoWarning": {
- "description": "Demo warning on deposit screen"
+ "swapDepositAddressCopied": "Deposit address copied to clipboard",
+ "@swapDepositAddressCopied": {
+ "description": "Toast when the deposit address is copied"
+ },
+ "swapDepositWaiting": "Waiting for your deposit…",
+ "@swapDepositWaiting": {
+ "description": "Shown while 1Click has not seen a deposit yet"
+ },
+ "swapDepositDeadline": "Send before {time} or the quote expires",
+ "@swapDepositDeadline": {
+ "description": "Local time by which the deposit must arrive",
+ "placeholders": {
+ "time": {
+ "type": "String"
+ }
+ }
},
"swapDepositShareQr": "Share QR",
"@swapDepositShareQr": {
@@ -2430,67 +2713,48 @@
}
}
},
- "swapDepositNotice": "Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.",
- "@swapDepositNotice": {
- "description": "Deposit wallet notice on deposit screen",
+ "swapDepositMemo": "Memo",
+ "@swapDepositMemo": {
+ "description": "Label of the deposit memo some chains require"
+ },
+ "swapDepositMemoCopied": "Deposit memo copied to clipboard",
+ "@swapDepositMemoCopied": {
+ "description": "Toast when the deposit memo is copied"
+ },
+ "swapDepositMemoNotice": "Include this memo with your deposit. A deposit without it is not processed.",
+ "@swapDepositMemoNotice": {
+ "description": "Warning under a deposit memo"
+ },
+ "swapDepositShareContentMemo": "Network: {network}\nToken: {token}\nAddress: {address}\nMemo: {memo}",
+ "@swapDepositShareContentMemo": {
+ "description": "Share text for deposit details with a memo",
"placeholders": {
- "symbol": {
+ "network": {
"type": "String"
},
- "network": {
+ "token": {
+ "type": "String"
+ },
+ "address": {
+ "type": "String"
+ },
+ "memo": {
"type": "String"
}
}
},
- "swapDepositProcessingTitle": "Processing Swap",
- "@swapDepositProcessingTitle": {
- "description": "Title while swap is processing"
- },
- "swapDepositProcessingBody": "This may take a few minutes...",
- "@swapDepositProcessingBody": {
- "description": "Body while swap is processing"
- },
- "swapDepositCompleteTitle": "Swap Complete",
- "@swapDepositCompleteTitle": {
- "description": "Title when swap is complete"
- },
- "swapDepositCompleteBody": "Your swap for {amount} {tokenSymbol} is complete.",
- "@swapDepositCompleteBody": {
- "description": "Body when swap is complete",
+ "swapDepositNotice": "Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.",
+ "@swapDepositNotice": {
+ "description": "Deposit wallet notice on deposit screen",
"placeholders": {
- "amount": {
+ "symbol": {
"type": "String"
},
- "tokenSymbol": {
+ "network": {
"type": "String"
}
}
},
- "swapDemoOnly": "Demo Only",
- "@swapDemoOnly": {
- "description": "Large banner making clear the swap flow is a demo and nothing is swapped"
- },
- "swapDemoOnlyBody": "No real swap was performed.",
- "@swapDemoOnlyBody": {
- "description": "Subtitle under the Demo Only banner"
- },
- "swapDepositSentFunds": "I've sent the funds",
- "@swapDepositSentFunds": {
- "description": "Button to confirm funds sent"
- },
- "swapDepositDone": "Done",
- "@swapDepositDone": {
- "description": "Done button after swap completes"
- },
-
- "swapRefundPickerTitle": "Refund Addresses",
- "@swapRefundPickerTitle": {
- "description": "Title on refund address picker sheet"
- },
- "swapRefundPickerEmpty": "No recent refund addresses",
- "@swapRefundPickerEmpty": {
- "description": "Empty state on refund address picker"
- },
"componentQrScannerTitle": "Scan QR Code",
"@componentQrScannerTitle": {
diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb
index ac1db31d0..161efd829 100644
--- a/mobile-app/lib/l10n/app_id.arb
+++ b/mobile-app/lib/l10n/app_id.arb
@@ -448,36 +448,83 @@
"swapPoweredBy": "Didukung Oleh",
"swapFrom": "Dari",
"swapTo": "Ke",
- "swapRefundAddress": "Alamat Refund",
- "swapRefundAddressHint": "Alamat {network}",
- "swapSlippageTolerance": "Toleransi Slippage",
+ "swapExternalWallet": "Dompet yang Anda kendalikan",
"swapRate": "Kurs",
- "swapGetQuote": "Dapatkan Penawaran",
- "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}",
- "swapRateZero": "1 {tokenSymbol} = 0 {symbol}",
+ "swapRateLabel": "1 {fromSymbol} = {rate} {toSymbol}",
+ "swapSlippageLabel": "Slippage {percent}%",
+ "swapSlippageTitle": "Toleransi Slippage",
+ "swapSlippageBody": "Jika harga bergerak lebih dari ini sebelum swap Anda terisi, dana dikembalikan. Toleransi lebih tinggi lebih sering terisi tetapi hasilnya bisa lebih sedikit.",
+ "swapSlippagePercent": "{percent}%",
+ "swapAddRecipientAddress": "Tambahkan Alamat Penerima",
+ "swapAddRefundAddress": "Tambahkan Alamat Refund",
"swapTokenPickerTitle": "Pilih Token",
"swapTokenPickerLoadError": "Gagal memuat token",
- "swapReviewTitle": "Tinjau Penawaran",
- "swapReviewTotalFees": "Total biaya",
- "swapReviewTotalAmount": "Jumlah Total",
- "swapReviewSlippageWarning": "Anda bisa menerima hingga ${amount} lebih sedikit berdasarkan slippage {percent}% yang Anda atur",
- "swapReviewConfirm": "Konfirmasi",
+ "swapQuoteError": "Penawaran gagal: {message}",
+ "swapRecipientAddressTitle": "Alamat Penerima",
+ "swapRefundAddressTitle": "Alamat Refund",
+ "swapAddressLabel": "Alamat {symbol}",
+ "swapAddressHint": "Alamat {network}",
+ "swapRecipientAddressNotice": "{symbol} Anda akan tiba di alamat ini. Periksa kembali, swap tidak dapat dibatalkan.",
+ "swapRefundAddressNotice": "Jika swap tidak dapat diselesaikan, {symbol} Anda dikembalikan ke alamat ini di {network}. Periksa kembali.",
+ "swapSaveAddress": "Simpan untuk swap berikutnya",
+ "swapContinue": "Lanjutkan ke Swap",
+ "swapSavedAddressesTitle": "Alamat Tersimpan",
+ "swapSavedAddressesEmpty": "Belum ada alamat tersimpan",
+ "swapReviewTitle": "Tinjau Swap",
+ "swapYouPay": "Anda bayar",
+ "swapYouReceive": "Anda terima",
+ "swapReviewRecipient": "Penerima",
+ "swapReviewRefundAddress": "Alamat refund",
+ "swapReviewNetworkFee": "Biaya jaringan",
+ "swapReviewFeeUnavailable": "Tidak tersedia",
+ "swapReviewFeeFailed": "Tidak dapat mengambil biaya jaringan.",
+ "swapReviewSlippage": "Toleransi slippage",
+ "swapReviewSlippageValue": "{amount} ({percent}%)",
+ "swapReviewGuaranteedMinimum": "Minimum terjamin",
+ "swapReviewInsufficient": "{symbol} tidak cukup untuk jumlah ini ditambah biaya jaringan.",
+ "swapReviewPriceMoved": "Harga berubah. Periksa jumlah baru dan konfirmasi lagi.",
+ "swapReviewConfirm": "Konfirmasi swap",
+ "swapInProgressTitle": "Swap Sedang Berjalan",
+ "swapYourePaying": "Anda membayar",
+ "swapYoureReceiving": "Anda menerima",
+ "swapStepSent": "{symbol} terkirim",
+ "swapStepConfirming": "Mengonfirmasi di {network}",
+ "swapStepSwapping": "Menukar",
+ "swapStepSending": "Mengirim {symbol} ke {recipient}",
+ "swapProgressFooter": "Biasanya beberapa menit. Swap tetap selesai meskipun Anda meninggalkan layar ini. · via NEAR Intents",
+ "swapDetailsTitle": "Detail Swap",
+ "swapDetailsRecipient": "Alamat penerima",
+ "swapDetailsAmountSent": "Jumlah dikirim",
+ "swapDetailsExpected": "Perkiraan",
+ "swapDetailsTransaction": "Transaksi",
+ "swapCompleteTitle": "Swap Selesai",
+ "swapCompleteReceived": "Diterima",
+ "swapCompleteTo": "Ke:",
+ "swapFailedTitle": "Swap Gagal",
+ "swapRefundedLabel": "Dikembalikan",
+ "swapYouPaid": "Anda telah membayar",
+ "swapYouWereReceiving": "Anda akan menerima",
+ "swapRefundedBody": "Swap tidak dapat diselesaikan, jadi {amount} Anda telah dikembalikan ke {destination}, dikurangi biaya jaringan. {symbol} tidak pernah dikirim.",
+ "swapDepositRefundReason": "Alasan: {reason}",
+ "swapDepositFailedBody": "Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: {address}",
+ "swapDepositIncompleteTitle": "Deposit Terlalu Kecil",
+ "swapDepositIncompleteBody": "Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.",
+ "swapDepositExpiredTitle": "Penawaran Kedaluwarsa",
+ "swapDepositExpiredBody": "Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.",
+ "swapStartNew": "Mulai Swap Baru",
+ "swapContactSupport": "Hubungi Dukungan",
"swapDepositAmount": "Jumlah Deposit",
"swapDepositAmountCopied": "Jumlah deposit disalin ke clipboard",
- "swapDepositDemoWarning": "Hanya untuk demo - jangan kirim dana!",
+ "swapDepositAddressCopied": "Alamat deposit disalin ke clipboard",
+ "swapDepositWaiting": "Menunggu deposit Anda…",
+ "swapDepositDeadline": "Kirim sebelum {time} atau penawaran kedaluwarsa",
"swapDepositShareQr": "Bagikan QR",
"swapDepositShareContent": "Jaringan: {network}\nToken: {token}\nAlamat: {address}",
+ "swapDepositMemo": "Memo",
+ "swapDepositMemoCopied": "Memo deposit disalin ke clipboard",
+ "swapDepositMemoNotice": "Sertakan memo ini pada deposit Anda. Deposit tanpa memo tidak akan diproses.",
+ "swapDepositShareContentMemo": "Jaringan: {network}\nToken: {token}\nAlamat: {address}\nMemo: {memo}",
"swapDepositNotice": "Gunakan dompet {symbol} atau {network} Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.",
- "swapDepositProcessingTitle": "Memproses Swap",
- "swapDepositProcessingBody": "Ini mungkin memakan waktu beberapa menit...",
- "swapDepositCompleteTitle": "Swap Selesai",
- "swapDepositCompleteBody": "Swap Anda untuk {amount} {tokenSymbol} telah selesai.",
- "swapDemoOnly": "Hanya Demo",
- "swapDemoOnlyBody": "Tidak ada swap sungguhan yang dilakukan.",
- "swapDepositSentFunds": "Saya sudah mengirim dana",
- "swapDepositDone": "Selesai",
- "swapRefundPickerTitle": "Alamat Refund",
- "swapRefundPickerEmpty": "Tidak ada alamat refund terbaru",
"componentQrScannerTitle": "Pindai Kode QR",
"componentQrScannerNoCode": "Tidak ada kode QR pada gambar",
"componentShare": "Bagikan",
diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart
index 5c50e0ceb..587ad4718 100644
--- a/mobile-app/lib/l10n/app_localizations.dart
+++ b/mobile-app/lib/l10n/app_localizations.dart
@@ -3002,59 +3002,71 @@ abstract class AppLocalizations {
/// **'Powered By'**
String get swapPoweredBy;
- /// From token section label on swap screen
+ /// Label of the side the swapped funds come from
///
/// In en, this message translates to:
/// **'From'**
String get swapFrom;
- /// To token section label on swap screen
+ /// Label of the side the swapped funds go to
///
/// In en, this message translates to:
/// **'To'**
String get swapTo;
- /// Refund address field label on swap screen
+ /// Owner of the external token side of the swap form
///
/// In en, this message translates to:
- /// **'Refund Address'**
- String get swapRefundAddress;
+ /// **'A wallet you control'**
+ String get swapExternalWallet;
- /// Refund address field hint
+ /// Exchange rate label on swap screen
///
/// In en, this message translates to:
- /// **'{network} Address'**
- String swapRefundAddressHint(String network);
+ /// **'Rate'**
+ String get swapRate;
+
+ /// Exchange rate: what one input token buys
+ ///
+ /// In en, this message translates to:
+ /// **'1 {fromSymbol} = {rate} {toSymbol}'**
+ String swapRateLabel(String fromSymbol, String rate, String toSymbol);
+
+ /// Slippage tolerance under the swap form
+ ///
+ /// In en, this message translates to:
+ /// **'Slippage {percent}%'**
+ String swapSlippageLabel(String percent);
- /// Slippage tolerance label on swap screen
+ /// Title of the slippage tolerance sheet
///
/// In en, this message translates to:
/// **'Slippage Tolerance'**
- String get swapSlippageTolerance;
+ String get swapSlippageTitle;
- /// Exchange rate label on swap screen
+ /// Explanation on the slippage tolerance sheet
///
/// In en, this message translates to:
- /// **'Rate'**
- String get swapRate;
+ /// **'If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.'**
+ String get swapSlippageBody;
- /// Get quote button on swap screen
+ /// A slippage tolerance option
///
/// In en, this message translates to:
- /// **'Get a Quote'**
- String get swapGetQuote;
+ /// **'{percent}%'**
+ String swapSlippagePercent(String percent);
- /// Exchange rate display
+ /// Swap form button for a swap out of QTC
///
/// In en, this message translates to:
- /// **'1 {tokenSymbol} = {amount} {symbol}'**
- String swapRateLabel(String amount, String symbol, String tokenSymbol);
+ /// **'Add Recipient Address'**
+ String get swapAddRecipientAddress;
- /// Exchange rate when amount is zero
+ /// Swap form button for a swap into QTC
///
/// In en, this message translates to:
- /// **'1 {tokenSymbol} = 0 {symbol}'**
- String swapRateZero(String symbol, String tokenSymbol);
+ /// **'Add Refund Address'**
+ String get swapAddRefundAddress;
/// Title on token picker sheet
///
@@ -3068,131 +3080,401 @@ abstract class AppLocalizations {
/// **'Failed to load tokens'**
String get swapTokenPickerLoadError;
- /// Title on review quote sheet
+ /// Toast when 1Click rejects a quote or a swap cannot be created
+ ///
+ /// In en, this message translates to:
+ /// **'Quote failed: {message}'**
+ String swapQuoteError(String message);
+
+ /// Title of the sheet asking where the swapped funds go
+ ///
+ /// In en, this message translates to:
+ /// **'Recipient Address'**
+ String get swapRecipientAddressTitle;
+
+ /// Title of the sheet asking where a failed swap is refunded
+ ///
+ /// In en, this message translates to:
+ /// **'Refund Address'**
+ String get swapRefundAddressTitle;
+
+ /// Address field label in the swap address sheet
///
/// In en, this message translates to:
- /// **'Review Quote'**
+ /// **'{symbol} Address'**
+ String swapAddressLabel(String symbol);
+
+ /// Address field hint in the swap address sheet
+ ///
+ /// In en, this message translates to:
+ /// **'{network} Address'**
+ String swapAddressHint(String network);
+
+ /// Warning under the recipient address field
+ ///
+ /// In en, this message translates to:
+ /// **'Your {symbol} will arrive at this address. Double-check it, swaps cannot be reversed.'**
+ String swapRecipientAddressNotice(String symbol);
+
+ /// Warning under the refund address field
+ ///
+ /// In en, this message translates to:
+ /// **'If the swap cannot complete, your {symbol} returns to this address on {network}. Double-check it.'**
+ String swapRefundAddressNotice(String symbol, String network);
+
+ /// Checkbox that saves the entered address
+ ///
+ /// In en, this message translates to:
+ /// **'Save for future swaps'**
+ String get swapSaveAddress;
+
+ /// Button that quotes the swap with the entered address
+ ///
+ /// In en, this message translates to:
+ /// **'Continue to Swap'**
+ String get swapContinue;
+
+ /// Title of the saved swap addresses sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Saved Addresses'**
+ String get swapSavedAddressesTitle;
+
+ /// Empty saved swap addresses sheet
+ ///
+ /// In en, this message translates to:
+ /// **'No saved addresses yet'**
+ String get swapSavedAddressesEmpty;
+
+ /// Title of the swap review screen
+ ///
+ /// In en, this message translates to:
+ /// **'Review Swap'**
String get swapReviewTitle;
- /// Total fees row on review quote sheet
+ /// Pay side of the swap review card
///
/// In en, this message translates to:
- /// **'Total fees'**
- String get swapReviewTotalFees;
+ /// **'You pay'**
+ String get swapYouPay;
- /// Total amount row on review quote sheet
+ /// Receive side of the swap review card
///
/// In en, this message translates to:
- /// **'Total Amount'**
- String get swapReviewTotalAmount;
+ /// **'You receive'**
+ String get swapYouReceive;
- /// Slippage warning on review quote sheet
+ /// Review row: external address receiving the swap
///
/// In en, this message translates to:
- /// **'You could receive up to \${amount} less based on the {percent}% slippage you set'**
- String swapReviewSlippageWarning(String amount, String percent);
+ /// **'Recipient'**
+ String get swapReviewRecipient;
- /// Confirm button on review quote sheet
+ /// Review row: external address a failed swap is refunded to
///
/// In en, this message translates to:
- /// **'Confirm'**
+ /// **'Refund address'**
+ String get swapReviewRefundAddress;
+
+ /// Review row and details row: chain fee of the QTC deposit
+ ///
+ /// In en, this message translates to:
+ /// **'Network fee'**
+ String get swapReviewNetworkFee;
+
+ /// Network fee value when it could not be fetched
+ ///
+ /// In en, this message translates to:
+ /// **'Unavailable'**
+ String get swapReviewFeeUnavailable;
+
+ /// Error under the review rows when the fee failed
+ ///
+ /// In en, this message translates to:
+ /// **'Could not get the network fee.'**
+ String get swapReviewFeeFailed;
+
+ /// Review row: how far below the quote the output may land
+ ///
+ /// In en, this message translates to:
+ /// **'Slippage allowance'**
+ String get swapReviewSlippage;
+
+ /// Slippage allowance value
+ ///
+ /// In en, this message translates to:
+ /// **'{amount} ({percent}%)'**
+ String swapReviewSlippageValue(String amount, String percent);
+
+ /// Review row: least the swap pays out
+ ///
+ /// In en, this message translates to:
+ /// **'Guaranteed minimum'**
+ String get swapReviewGuaranteedMinimum;
+
+ /// Review error when the balance cannot cover the deposit
+ ///
+ /// In en, this message translates to:
+ /// **'Not enough {symbol} for this amount plus the network fee.'**
+ String swapReviewInsufficient(String symbol);
+
+ /// Toast when the live quote guarantees less than the one reviewed
+ ///
+ /// In en, this message translates to:
+ /// **'The price moved. Check the new amounts and confirm again.'**
+ String get swapReviewPriceMoved;
+
+ /// Confirm button on the swap review screen
+ ///
+ /// In en, this message translates to:
+ /// **'Confirm swap'**
String get swapReviewConfirm;
- /// Deposit amount label on deposit screen
+ /// Title while the swap is running
///
/// In en, this message translates to:
- /// **'Deposit Amount'**
- String get swapDepositAmount;
+ /// **'Swap In Progress'**
+ String get swapInProgressTitle;
- /// Toast when deposit amount is copied
+ /// Pay side of the swap progress card
///
/// In en, this message translates to:
- /// **'Deposit amount copied to clipboard'**
- String get swapDepositAmountCopied;
+ /// **'You\'re paying'**
+ String get swapYourePaying;
- /// Demo warning on deposit screen
+ /// Receive side of the swap progress card
///
/// In en, this message translates to:
- /// **'For demo purposes only - do not send funds!'**
- String get swapDepositDemoWarning;
+ /// **'You\'re receiving'**
+ String get swapYoureReceiving;
- /// Share QR button on deposit screen
+ /// Progress step: deposit sent
///
/// In en, this message translates to:
- /// **'Share QR'**
- String get swapDepositShareQr;
+ /// **'{symbol} sent'**
+ String swapStepSent(String symbol);
- /// Share text for deposit details
+ /// Progress step: deposit confirming
///
/// In en, this message translates to:
- /// **'Network: {network}\nToken: {token}\nAddress: {address}'**
- String swapDepositShareContent(String network, String token, String address);
+ /// **'Confirming on {network}'**
+ String swapStepConfirming(String network);
- /// Deposit wallet notice on deposit screen
+ /// Progress step: solvers swapping
///
/// In en, this message translates to:
- /// **'Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.'**
- String swapDepositNotice(String symbol, String network);
+ /// **'Swapping'**
+ String get swapStepSwapping;
- /// Title while swap is processing
+ /// Progress step: payout
///
/// In en, this message translates to:
- /// **'Processing Swap'**
- String get swapDepositProcessingTitle;
+ /// **'Sending {symbol} to {recipient}'**
+ String swapStepSending(String symbol, String recipient);
- /// Body while swap is processing
+ /// Footer under the swap progress bar
///
/// In en, this message translates to:
- /// **'This may take a few minutes...'**
- String get swapDepositProcessingBody;
+ /// **'Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents'**
+ String get swapProgressFooter;
- /// Title when swap is complete
+ /// Title of the swap details sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Swap Details'**
+ String get swapDetailsTitle;
+
+ /// Details row: address receiving the swap
+ ///
+ /// In en, this message translates to:
+ /// **'Recipient address'**
+ String get swapDetailsRecipient;
+
+ /// Details row: deposit amount
+ ///
+ /// In en, this message translates to:
+ /// **'Amount sent'**
+ String get swapDetailsAmountSent;
+
+ /// Details row: quoted payout
+ ///
+ /// In en, this message translates to:
+ /// **'Expected'**
+ String get swapDetailsExpected;
+
+ /// Details row: deposit transaction hash
+ ///
+ /// In en, this message translates to:
+ /// **'Transaction'**
+ String get swapDetailsTransaction;
+
+ /// Title when the swap paid out
///
/// In en, this message translates to:
/// **'Swap Complete'**
- String get swapDepositCompleteTitle;
+ String get swapCompleteTitle;
- /// Body when swap is complete
+ /// Caption under the paid-out amount
///
/// In en, this message translates to:
- /// **'Your swap for {amount} {tokenSymbol} is complete.'**
- String swapDepositCompleteBody(String amount, String tokenSymbol);
+ /// **'Received'**
+ String get swapCompleteReceived;
- /// Large banner making clear the swap flow is a demo and nothing is swapped
+ /// Label above where the swap paid out
///
/// In en, this message translates to:
- /// **'Demo Only'**
- String get swapDemoOnly;
+ /// **'To:'**
+ String get swapCompleteTo;
- /// Subtitle under the Demo Only banner
+ /// Title when the swap did not pay out
///
/// In en, this message translates to:
- /// **'No real swap was performed.'**
- String get swapDemoOnlyBody;
+ /// **'Swap Failed'**
+ String get swapFailedTitle;
- /// Button to confirm funds sent
+ /// Status label on a refunded swap
///
/// In en, this message translates to:
- /// **'I\'ve sent the funds'**
- String get swapDepositSentFunds;
+ /// **'Refunded'**
+ String get swapRefundedLabel;
- /// Done button after swap completes
+ /// Pay side of a failed swap card
///
/// In en, this message translates to:
- /// **'Done'**
- String get swapDepositDone;
+ /// **'You paid'**
+ String get swapYouPaid;
+
+ /// Receive side of a failed swap card
+ ///
+ /// In en, this message translates to:
+ /// **'You were receiving'**
+ String get swapYouWereReceiving;
+
+ /// Banner on a refunded swap
+ ///
+ /// In en, this message translates to:
+ /// **'The swap could not be completed, so your {amount} has been returned to {destination}, minus the network fee. The {symbol} was never sent.'**
+ String swapRefundedBody(String amount, String destination, String symbol);
+
+ /// Refund reason reported by 1Click
+ ///
+ /// In en, this message translates to:
+ /// **'Reason: {reason}'**
+ String swapDepositRefundReason(String reason);
+
+ /// Body when 1Click reports the swap failed
+ ///
+ /// In en, this message translates to:
+ /// **'The swap did not complete. Contact support and quote this deposit address: {address}'**
+ String swapDepositFailedBody(String address);
+
+ /// Title when less than the quoted amount arrived
+ ///
+ /// In en, this message translates to:
+ /// **'Deposit Too Small'**
+ String get swapDepositIncompleteTitle;
+
+ /// Body when less than the quoted amount arrived
+ ///
+ /// In en, this message translates to:
+ /// **'Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.'**
+ String get swapDepositIncompleteBody;
+
+ /// Title when the deadline passed with no deposit
+ ///
+ /// In en, this message translates to:
+ /// **'Quote Expired'**
+ String get swapDepositExpiredTitle;
+
+ /// Body when the deadline passed with no deposit
+ ///
+ /// In en, this message translates to:
+ /// **'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.'**
+ String get swapDepositExpiredBody;
+
+ /// Button on a failed swap
+ ///
+ /// In en, this message translates to:
+ /// **'Start a New Swap'**
+ String get swapStartNew;
+
+ /// Link on a failed swap
+ ///
+ /// In en, this message translates to:
+ /// **'Contact Support'**
+ String get swapContactSupport;
+
+ /// Deposit amount label on deposit screen
+ ///
+ /// In en, this message translates to:
+ /// **'Deposit Amount'**
+ String get swapDepositAmount;
+
+ /// Toast when deposit amount is copied
+ ///
+ /// In en, this message translates to:
+ /// **'Deposit amount copied to clipboard'**
+ String get swapDepositAmountCopied;
+
+ /// Toast when the deposit address is copied
+ ///
+ /// In en, this message translates to:
+ /// **'Deposit address copied to clipboard'**
+ String get swapDepositAddressCopied;
+
+ /// Shown while 1Click has not seen a deposit yet
+ ///
+ /// In en, this message translates to:
+ /// **'Waiting for your deposit…'**
+ String get swapDepositWaiting;
+
+ /// Local time by which the deposit must arrive
+ ///
+ /// In en, this message translates to:
+ /// **'Send before {time} or the quote expires'**
+ String swapDepositDeadline(String time);
+
+ /// Share QR button on deposit screen
+ ///
+ /// In en, this message translates to:
+ /// **'Share QR'**
+ String get swapDepositShareQr;
+
+ /// Share text for deposit details
+ ///
+ /// In en, this message translates to:
+ /// **'Network: {network}\nToken: {token}\nAddress: {address}'**
+ String swapDepositShareContent(String network, String token, String address);
- /// Title on refund address picker sheet
+ /// Label of the deposit memo some chains require
///
/// In en, this message translates to:
- /// **'Refund Addresses'**
- String get swapRefundPickerTitle;
+ /// **'Memo'**
+ String get swapDepositMemo;
- /// Empty state on refund address picker
+ /// Toast when the deposit memo is copied
///
/// In en, this message translates to:
- /// **'No recent refund addresses'**
- String get swapRefundPickerEmpty;
+ /// **'Deposit memo copied to clipboard'**
+ String get swapDepositMemoCopied;
+
+ /// Warning under a deposit memo
+ ///
+ /// In en, this message translates to:
+ /// **'Include this memo with your deposit. A deposit without it is not processed.'**
+ String get swapDepositMemoNotice;
+
+ /// Share text for deposit details with a memo
+ ///
+ /// In en, this message translates to:
+ /// **'Network: {network}\nToken: {token}\nAddress: {address}\nMemo: {memo}'**
+ String swapDepositShareContentMemo(String network, String token, String address, String memo);
+
+ /// Deposit wallet notice on deposit screen
+ ///
+ /// In en, this message translates to:
+ /// **'Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.'**
+ String swapDepositNotice(String symbol, String network);
/// Text for app bar or button label on QR scanner component
///
diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart
index aca4c8ed1..c4e622857 100644
--- a/mobile-app/lib/l10n/app_localizations_en.dart
+++ b/mobile-app/lib/l10n/app_localizations_en.dart
@@ -1598,31 +1598,38 @@ class AppLocalizationsEn extends AppLocalizations {
String get swapTo => 'To';
@override
- String get swapRefundAddress => 'Refund Address';
+ String get swapExternalWallet => 'A wallet you control';
@override
- String swapRefundAddressHint(String network) {
- return '$network Address';
+ String get swapRate => 'Rate';
+
+ @override
+ String swapRateLabel(String fromSymbol, String rate, String toSymbol) {
+ return '1 $fromSymbol = $rate $toSymbol';
}
@override
- String get swapSlippageTolerance => 'Slippage Tolerance';
+ String swapSlippageLabel(String percent) {
+ return 'Slippage $percent%';
+ }
@override
- String get swapRate => 'Rate';
+ String get swapSlippageTitle => 'Slippage Tolerance';
@override
- String get swapGetQuote => 'Get a Quote';
+ String get swapSlippageBody =>
+ 'If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.';
@override
- String swapRateLabel(String amount, String symbol, String tokenSymbol) {
- return '1 $tokenSymbol = $amount $symbol';
+ String swapSlippagePercent(String percent) {
+ return '$percent%';
}
@override
- String swapRateZero(String symbol, String tokenSymbol) {
- return '1 $tokenSymbol = 0 $symbol';
- }
+ String get swapAddRecipientAddress => 'Add Recipient Address';
+
+ @override
+ String get swapAddRefundAddress => 'Add Refund Address';
@override
String get swapTokenPickerTitle => 'Select Token';
@@ -1631,75 +1638,239 @@ class AppLocalizationsEn extends AppLocalizations {
String get swapTokenPickerLoadError => 'Failed to load tokens';
@override
- String get swapReviewTitle => 'Review Quote';
+ String swapQuoteError(String message) {
+ return 'Quote failed: $message';
+ }
@override
- String get swapReviewTotalFees => 'Total fees';
+ String get swapRecipientAddressTitle => 'Recipient Address';
@override
- String get swapReviewTotalAmount => 'Total Amount';
+ String get swapRefundAddressTitle => 'Refund Address';
@override
- String swapReviewSlippageWarning(String amount, String percent) {
- return 'You could receive up to \$$amount less based on the $percent% slippage you set';
+ String swapAddressLabel(String symbol) {
+ return '$symbol Address';
}
@override
- String get swapReviewConfirm => 'Confirm';
+ String swapAddressHint(String network) {
+ return '$network Address';
+ }
@override
- String get swapDepositAmount => 'Deposit Amount';
+ String swapRecipientAddressNotice(String symbol) {
+ return 'Your $symbol will arrive at this address. Double-check it, swaps cannot be reversed.';
+ }
@override
- String get swapDepositAmountCopied => 'Deposit amount copied to clipboard';
+ String swapRefundAddressNotice(String symbol, String network) {
+ return 'If the swap cannot complete, your $symbol returns to this address on $network. Double-check it.';
+ }
@override
- String get swapDepositDemoWarning => 'For demo purposes only - do not send funds!';
+ String get swapSaveAddress => 'Save for future swaps';
@override
- String get swapDepositShareQr => 'Share QR';
+ String get swapContinue => 'Continue to Swap';
@override
- String swapDepositShareContent(String network, String token, String address) {
- return 'Network: $network\nToken: $token\nAddress: $address';
+ String get swapSavedAddressesTitle => 'Saved Addresses';
+
+ @override
+ String get swapSavedAddressesEmpty => 'No saved addresses yet';
+
+ @override
+ String get swapReviewTitle => 'Review Swap';
+
+ @override
+ String get swapYouPay => 'You pay';
+
+ @override
+ String get swapYouReceive => 'You receive';
+
+ @override
+ String get swapReviewRecipient => 'Recipient';
+
+ @override
+ String get swapReviewRefundAddress => 'Refund address';
+
+ @override
+ String get swapReviewNetworkFee => 'Network fee';
+
+ @override
+ String get swapReviewFeeUnavailable => 'Unavailable';
+
+ @override
+ String get swapReviewFeeFailed => 'Could not get the network fee.';
+
+ @override
+ String get swapReviewSlippage => 'Slippage allowance';
+
+ @override
+ String swapReviewSlippageValue(String amount, String percent) {
+ return '$amount ($percent%)';
}
@override
- String swapDepositNotice(String symbol, String network) {
- return 'Use your $symbol or $network wallet to deposit funds. Depositing other assets may result in loss of funds.';
+ String get swapReviewGuaranteedMinimum => 'Guaranteed minimum';
+
+ @override
+ String swapReviewInsufficient(String symbol) {
+ return 'Not enough $symbol for this amount plus the network fee.';
}
@override
- String get swapDepositProcessingTitle => 'Processing Swap';
+ String get swapReviewPriceMoved => 'The price moved. Check the new amounts and confirm again.';
+
+ @override
+ String get swapReviewConfirm => 'Confirm swap';
+
+ @override
+ String get swapInProgressTitle => 'Swap In Progress';
+
+ @override
+ String get swapYourePaying => 'You\'re paying';
+
+ @override
+ String get swapYoureReceiving => 'You\'re receiving';
+
+ @override
+ String swapStepSent(String symbol) {
+ return '$symbol sent';
+ }
@override
- String get swapDepositProcessingBody => 'This may take a few minutes...';
+ String swapStepConfirming(String network) {
+ return 'Confirming on $network';
+ }
@override
- String get swapDepositCompleteTitle => 'Swap Complete';
+ String get swapStepSwapping => 'Swapping';
@override
- String swapDepositCompleteBody(String amount, String tokenSymbol) {
- return 'Your swap for $amount $tokenSymbol is complete.';
+ String swapStepSending(String symbol, String recipient) {
+ return 'Sending $symbol to $recipient';
}
@override
- String get swapDemoOnly => 'Demo Only';
+ String get swapProgressFooter =>
+ 'Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents';
+
+ @override
+ String get swapDetailsTitle => 'Swap Details';
+
+ @override
+ String get swapDetailsRecipient => 'Recipient address';
+
+ @override
+ String get swapDetailsAmountSent => 'Amount sent';
@override
- String get swapDemoOnlyBody => 'No real swap was performed.';
+ String get swapDetailsExpected => 'Expected';
@override
- String get swapDepositSentFunds => 'I\'ve sent the funds';
+ String get swapDetailsTransaction => 'Transaction';
@override
- String get swapDepositDone => 'Done';
+ String get swapCompleteTitle => 'Swap Complete';
@override
- String get swapRefundPickerTitle => 'Refund Addresses';
+ String get swapCompleteReceived => 'Received';
@override
- String get swapRefundPickerEmpty => 'No recent refund addresses';
+ String get swapCompleteTo => 'To:';
+
+ @override
+ String get swapFailedTitle => 'Swap Failed';
+
+ @override
+ String get swapRefundedLabel => 'Refunded';
+
+ @override
+ String get swapYouPaid => 'You paid';
+
+ @override
+ String get swapYouWereReceiving => 'You were receiving';
+
+ @override
+ String swapRefundedBody(String amount, String destination, String symbol) {
+ return 'The swap could not be completed, so your $amount has been returned to $destination, minus the network fee. The $symbol was never sent.';
+ }
+
+ @override
+ String swapDepositRefundReason(String reason) {
+ return 'Reason: $reason';
+ }
+
+ @override
+ String swapDepositFailedBody(String address) {
+ return 'The swap did not complete. Contact support and quote this deposit address: $address';
+ }
+
+ @override
+ String get swapDepositIncompleteTitle => 'Deposit Too Small';
+
+ @override
+ String get swapDepositIncompleteBody =>
+ 'Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.';
+
+ @override
+ String get swapDepositExpiredTitle => 'Quote Expired';
+
+ @override
+ String get swapDepositExpiredBody =>
+ 'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.';
+
+ @override
+ String get swapStartNew => 'Start a New Swap';
+
+ @override
+ String get swapContactSupport => 'Contact Support';
+
+ @override
+ String get swapDepositAmount => 'Deposit Amount';
+
+ @override
+ String get swapDepositAmountCopied => 'Deposit amount copied to clipboard';
+
+ @override
+ String get swapDepositAddressCopied => 'Deposit address copied to clipboard';
+
+ @override
+ String get swapDepositWaiting => 'Waiting for your deposit…';
+
+ @override
+ String swapDepositDeadline(String time) {
+ return 'Send before $time or the quote expires';
+ }
+
+ @override
+ String get swapDepositShareQr => 'Share QR';
+
+ @override
+ String swapDepositShareContent(String network, String token, String address) {
+ return 'Network: $network\nToken: $token\nAddress: $address';
+ }
+
+ @override
+ String get swapDepositMemo => 'Memo';
+
+ @override
+ String get swapDepositMemoCopied => 'Deposit memo copied to clipboard';
+
+ @override
+ String get swapDepositMemoNotice => 'Include this memo with your deposit. A deposit without it is not processed.';
+
+ @override
+ String swapDepositShareContentMemo(String network, String token, String address, String memo) {
+ return 'Network: $network\nToken: $token\nAddress: $address\nMemo: $memo';
+ }
+
+ @override
+ String swapDepositNotice(String symbol, String network) {
+ return 'Use your $symbol or $network wallet to deposit funds. Depositing other assets may result in loss of funds.';
+ }
@override
String get componentQrScannerTitle => 'Scan QR Code';
diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart
index a3d71bbb2..b1810bb58 100644
--- a/mobile-app/lib/l10n/app_localizations_id.dart
+++ b/mobile-app/lib/l10n/app_localizations_id.dart
@@ -1601,31 +1601,38 @@ class AppLocalizationsId extends AppLocalizations {
String get swapTo => 'Ke';
@override
- String get swapRefundAddress => 'Alamat Refund';
+ String get swapExternalWallet => 'Dompet yang Anda kendalikan';
@override
- String swapRefundAddressHint(String network) {
- return 'Alamat $network';
+ String get swapRate => 'Kurs';
+
+ @override
+ String swapRateLabel(String fromSymbol, String rate, String toSymbol) {
+ return '1 $fromSymbol = $rate $toSymbol';
}
@override
- String get swapSlippageTolerance => 'Toleransi Slippage';
+ String swapSlippageLabel(String percent) {
+ return 'Slippage $percent%';
+ }
@override
- String get swapRate => 'Kurs';
+ String get swapSlippageTitle => 'Toleransi Slippage';
@override
- String get swapGetQuote => 'Dapatkan Penawaran';
+ String get swapSlippageBody =>
+ 'Jika harga bergerak lebih dari ini sebelum swap Anda terisi, dana dikembalikan. Toleransi lebih tinggi lebih sering terisi tetapi hasilnya bisa lebih sedikit.';
@override
- String swapRateLabel(String amount, String symbol, String tokenSymbol) {
- return '1 $tokenSymbol = $amount $symbol';
+ String swapSlippagePercent(String percent) {
+ return '$percent%';
}
@override
- String swapRateZero(String symbol, String tokenSymbol) {
- return '1 $tokenSymbol = 0 $symbol';
- }
+ String get swapAddRecipientAddress => 'Tambahkan Alamat Penerima';
+
+ @override
+ String get swapAddRefundAddress => 'Tambahkan Alamat Refund';
@override
String get swapTokenPickerTitle => 'Pilih Token';
@@ -1634,75 +1641,239 @@ class AppLocalizationsId extends AppLocalizations {
String get swapTokenPickerLoadError => 'Gagal memuat token';
@override
- String get swapReviewTitle => 'Tinjau Penawaran';
+ String swapQuoteError(String message) {
+ return 'Penawaran gagal: $message';
+ }
@override
- String get swapReviewTotalFees => 'Total biaya';
+ String get swapRecipientAddressTitle => 'Alamat Penerima';
@override
- String get swapReviewTotalAmount => 'Jumlah Total';
+ String get swapRefundAddressTitle => 'Alamat Refund';
@override
- String swapReviewSlippageWarning(String amount, String percent) {
- return 'Anda bisa menerima hingga \$$amount lebih sedikit berdasarkan slippage $percent% yang Anda atur';
+ String swapAddressLabel(String symbol) {
+ return 'Alamat $symbol';
}
@override
- String get swapReviewConfirm => 'Konfirmasi';
+ String swapAddressHint(String network) {
+ return 'Alamat $network';
+ }
@override
- String get swapDepositAmount => 'Jumlah Deposit';
+ String swapRecipientAddressNotice(String symbol) {
+ return '$symbol Anda akan tiba di alamat ini. Periksa kembali, swap tidak dapat dibatalkan.';
+ }
@override
- String get swapDepositAmountCopied => 'Jumlah deposit disalin ke clipboard';
+ String swapRefundAddressNotice(String symbol, String network) {
+ return 'Jika swap tidak dapat diselesaikan, $symbol Anda dikembalikan ke alamat ini di $network. Periksa kembali.';
+ }
@override
- String get swapDepositDemoWarning => 'Hanya untuk demo - jangan kirim dana!';
+ String get swapSaveAddress => 'Simpan untuk swap berikutnya';
@override
- String get swapDepositShareQr => 'Bagikan QR';
+ String get swapContinue => 'Lanjutkan ke Swap';
@override
- String swapDepositShareContent(String network, String token, String address) {
- return 'Jaringan: $network\nToken: $token\nAlamat: $address';
+ String get swapSavedAddressesTitle => 'Alamat Tersimpan';
+
+ @override
+ String get swapSavedAddressesEmpty => 'Belum ada alamat tersimpan';
+
+ @override
+ String get swapReviewTitle => 'Tinjau Swap';
+
+ @override
+ String get swapYouPay => 'Anda bayar';
+
+ @override
+ String get swapYouReceive => 'Anda terima';
+
+ @override
+ String get swapReviewRecipient => 'Penerima';
+
+ @override
+ String get swapReviewRefundAddress => 'Alamat refund';
+
+ @override
+ String get swapReviewNetworkFee => 'Biaya jaringan';
+
+ @override
+ String get swapReviewFeeUnavailable => 'Tidak tersedia';
+
+ @override
+ String get swapReviewFeeFailed => 'Tidak dapat mengambil biaya jaringan.';
+
+ @override
+ String get swapReviewSlippage => 'Toleransi slippage';
+
+ @override
+ String swapReviewSlippageValue(String amount, String percent) {
+ return '$amount ($percent%)';
}
@override
- String swapDepositNotice(String symbol, String network) {
- return 'Gunakan dompet $symbol atau $network Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.';
+ String get swapReviewGuaranteedMinimum => 'Minimum terjamin';
+
+ @override
+ String swapReviewInsufficient(String symbol) {
+ return '$symbol tidak cukup untuk jumlah ini ditambah biaya jaringan.';
}
@override
- String get swapDepositProcessingTitle => 'Memproses Swap';
+ String get swapReviewPriceMoved => 'Harga berubah. Periksa jumlah baru dan konfirmasi lagi.';
+
+ @override
+ String get swapReviewConfirm => 'Konfirmasi swap';
+
+ @override
+ String get swapInProgressTitle => 'Swap Sedang Berjalan';
+
+ @override
+ String get swapYourePaying => 'Anda membayar';
+
+ @override
+ String get swapYoureReceiving => 'Anda menerima';
+
+ @override
+ String swapStepSent(String symbol) {
+ return '$symbol terkirim';
+ }
@override
- String get swapDepositProcessingBody => 'Ini mungkin memakan waktu beberapa menit...';
+ String swapStepConfirming(String network) {
+ return 'Mengonfirmasi di $network';
+ }
@override
- String get swapDepositCompleteTitle => 'Swap Selesai';
+ String get swapStepSwapping => 'Menukar';
@override
- String swapDepositCompleteBody(String amount, String tokenSymbol) {
- return 'Swap Anda untuk $amount $tokenSymbol telah selesai.';
+ String swapStepSending(String symbol, String recipient) {
+ return 'Mengirim $symbol ke $recipient';
}
@override
- String get swapDemoOnly => 'Hanya Demo';
+ String get swapProgressFooter =>
+ 'Biasanya beberapa menit. Swap tetap selesai meskipun Anda meninggalkan layar ini. · via NEAR Intents';
+
+ @override
+ String get swapDetailsTitle => 'Detail Swap';
+
+ @override
+ String get swapDetailsRecipient => 'Alamat penerima';
+
+ @override
+ String get swapDetailsAmountSent => 'Jumlah dikirim';
@override
- String get swapDemoOnlyBody => 'Tidak ada swap sungguhan yang dilakukan.';
+ String get swapDetailsExpected => 'Perkiraan';
@override
- String get swapDepositSentFunds => 'Saya sudah mengirim dana';
+ String get swapDetailsTransaction => 'Transaksi';
@override
- String get swapDepositDone => 'Selesai';
+ String get swapCompleteTitle => 'Swap Selesai';
@override
- String get swapRefundPickerTitle => 'Alamat Refund';
+ String get swapCompleteReceived => 'Diterima';
@override
- String get swapRefundPickerEmpty => 'Tidak ada alamat refund terbaru';
+ String get swapCompleteTo => 'Ke:';
+
+ @override
+ String get swapFailedTitle => 'Swap Gagal';
+
+ @override
+ String get swapRefundedLabel => 'Dikembalikan';
+
+ @override
+ String get swapYouPaid => 'Anda telah membayar';
+
+ @override
+ String get swapYouWereReceiving => 'Anda akan menerima';
+
+ @override
+ String swapRefundedBody(String amount, String destination, String symbol) {
+ return 'Swap tidak dapat diselesaikan, jadi $amount Anda telah dikembalikan ke $destination, dikurangi biaya jaringan. $symbol tidak pernah dikirim.';
+ }
+
+ @override
+ String swapDepositRefundReason(String reason) {
+ return 'Alasan: $reason';
+ }
+
+ @override
+ String swapDepositFailedBody(String address) {
+ return 'Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: $address';
+ }
+
+ @override
+ String get swapDepositIncompleteTitle => 'Deposit Terlalu Kecil';
+
+ @override
+ String get swapDepositIncompleteBody =>
+ 'Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.';
+
+ @override
+ String get swapDepositExpiredTitle => 'Penawaran Kedaluwarsa';
+
+ @override
+ String get swapDepositExpiredBody =>
+ 'Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.';
+
+ @override
+ String get swapStartNew => 'Mulai Swap Baru';
+
+ @override
+ String get swapContactSupport => 'Hubungi Dukungan';
+
+ @override
+ String get swapDepositAmount => 'Jumlah Deposit';
+
+ @override
+ String get swapDepositAmountCopied => 'Jumlah deposit disalin ke clipboard';
+
+ @override
+ String get swapDepositAddressCopied => 'Alamat deposit disalin ke clipboard';
+
+ @override
+ String get swapDepositWaiting => 'Menunggu deposit Anda…';
+
+ @override
+ String swapDepositDeadline(String time) {
+ return 'Kirim sebelum $time atau penawaran kedaluwarsa';
+ }
+
+ @override
+ String get swapDepositShareQr => 'Bagikan QR';
+
+ @override
+ String swapDepositShareContent(String network, String token, String address) {
+ return 'Jaringan: $network\nToken: $token\nAlamat: $address';
+ }
+
+ @override
+ String get swapDepositMemo => 'Memo';
+
+ @override
+ String get swapDepositMemoCopied => 'Memo deposit disalin ke clipboard';
+
+ @override
+ String get swapDepositMemoNotice => 'Sertakan memo ini pada deposit Anda. Deposit tanpa memo tidak akan diproses.';
+
+ @override
+ String swapDepositShareContentMemo(String network, String token, String address, String memo) {
+ return 'Jaringan: $network\nToken: $token\nAlamat: $address\nMemo: $memo';
+ }
+
+ @override
+ String swapDepositNotice(String symbol, String network) {
+ return 'Gunakan dompet $symbol atau $network Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.';
+ }
@override
String get componentQrScannerTitle => 'Pindai Kode QR';
diff --git a/mobile-app/lib/providers/mainnet_migration_provider.dart b/mobile-app/lib/providers/mainnet_migration_provider.dart
index 92f1c5879..72809e7c5 100644
--- a/mobile-app/lib/providers/mainnet_migration_provider.dart
+++ b/mobile-app/lib/providers/mainnet_migration_provider.dart
@@ -1,6 +1,4 @@
-import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_riverpod/legacy.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/services/mainnet_migration_service.dart';
@@ -13,26 +11,15 @@ final mainnetMigrationServiceProvider = Provider(
),
);
-/// Debug builds show the notice on every launch; its checking page then picks
-/// the testnet outcome at runtime, no real testnet data needed.
-final debugMainnetMigrationProvider = Provider((_) => kDebugMode && AppConstants.debugMainnetMigration);
-
-/// A picked outcome stands in for the testnet check; null runs the real one.
-final forcedTestnetOutcomeProvider = StateProvider((_) => null);
-
-const debugTestnetOutcomes = ['miner', 'holder', 'newcomer', 'error'];
-
/// Whether the notice is still due.
final mainnetMigrationPendingProvider = Provider((ref) {
if (AppConstants.runOnTestnet) return false;
- return ref.watch(debugMainnetMigrationProvider) || ref.watch(mainnetMigrationServiceProvider).isPending();
+ return ref.watch(mainnetMigrationServiceProvider).isPending();
});
/// The one-shot check is not retried: the unreachable page is written for it,
/// and a retry would flip it back to the checking page.
final testnetStatusProvider = FutureProvider((ref) async {
- final forced = ref.watch(forcedTestnetOutcomeProvider);
- if (forced != null) return _forcedStatus(forced);
try {
return await ref.watch(mainnetMigrationServiceProvider).checkTestnetStatus();
} catch (e) {
@@ -40,11 +27,3 @@ final testnetStatusProvider = FutureProvider((ref) async {
rethrow;
}
}, retry: (_, _) => null);
-
-TestnetStatus _forcedStatus(String outcome) => switch (outcome) {
- 'miner' => TestnetStatus(blocksMined: 1234, balance: BigInt.zero),
- 'holder' => TestnetStatus(blocksMined: 0, balance: BigInt.from(7) * BigInt.from(10).pow(AppConstants.decimals)),
- 'newcomer' => TestnetStatus(blocksMined: 0, balance: BigInt.zero),
- 'error' => throw Exception('debugMainnetMigration: testnet unreachable'),
- _ => throw ArgumentError.value(outcome, 'debugMainnetMigration'),
-};
diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/shared/utils/account_utils.dart
index 7be6d8756..a3c7a560b 100644
--- a/mobile-app/lib/shared/utils/account_utils.dart
+++ b/mobile-app/lib/shared/utils/account_utils.dart
@@ -18,6 +18,9 @@ int walletIndexForActiveAccount(List accounts, DisplayAccount? activeDi
/// that path for testing.
extension AccountSigning on Account {
bool get signsWithHardware => accountType == AccountType.keystone || AppConstants.debugHardwareWallet;
+
+ /// A transparent account whose key signs in this app.
+ bool get signsLocally => accountType == AccountType.local && !signsWithHardware;
}
List getNonHardwareWalletIndices(List accounts) {
diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart
index 07a2f9064..0753ecad9 100644
--- a/mobile-app/lib/v2/components/token_icon.dart
+++ b/mobile-app/lib/v2/components/token_icon.dart
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
class TokenIcon extends StatelessWidget {
+ static const _quantusAsset = 'assets/v2/token_qtc.svg';
+
final SwapToken token;
final double size;
final double networkBadgeSize;
@@ -12,9 +15,18 @@ class TokenIcon extends StatelessWidget {
Widget build(BuildContext context) {
final colors = context.colorsV3;
final text = context.themeTextV3;
- final swapService = SwapService();
- final iconUrl = token.iconUrl ?? swapService.getTokenIconUrl(token);
- final networkIconUrl = token.networkIconUrl ?? swapService.getNetworkIconUrl(token);
+ final iconUrl = token.iconUrl;
+ final networkIconUrl = token.networkIconUrl;
+
+ if (token.isQuantus) {
+ return Container(
+ width: size,
+ height: size,
+ padding: EdgeInsets.all(size * 0.18),
+ decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle),
+ child: SvgPicture.asset(_quantusAsset),
+ );
+ }
return SizedBox(
width: size,
diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart
index 68e4a2947..faa57105b 100644
--- a/mobile-app/lib/v2/screens/home/home_screen.dart
+++ b/mobile-app/lib/v2/screens/home/home_screen.dart
@@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/v2/components/shared_address_action_she
import 'package:resonance_network_wallet/services/global_history_polling_service.dart';
import 'package:resonance_network_wallet/services/telemetry_service.dart';
import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart';
+import 'package:resonance_network_wallet/shared/utils/account_utils.dart';
import 'package:resonance_network_wallet/shared/utils/print.dart';
import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart';
import 'package:resonance_network_wallet/v2/components/private_activity_notice.dart';
@@ -367,10 +368,11 @@ class _HomeScreenState extends ConsumerState {
final swapCard = _actionCard(
iconAsset: 'assets/v2/action_swap.svg',
label: l10n.homeSwap,
- // Swap is not available for encrypted accounts; keep the button visible
- // but disabled so the layout doesn't change between account types.
- isDisabled: isEncrypted,
- onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SwapScreen())),
+ // Swap signs the deposit locally, so only transparent accounts with a key
+ // here can swap; keep the button visible but disabled for the others so
+ // the layout doesn't change between account types.
+ isDisabled: !account.signsLocally,
+ onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SwapScreen(account: account))),
);
return Row(
diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart
index 514ee6d25..bc017cb59 100644
--- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart
+++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart
@@ -87,7 +87,7 @@ class RegularSendStrategy extends SendStrategy {
/// Dummy-signed `payment_queryInfo` probe from the captured account, with
/// its inputs resolved now so it can run after the requesting screen is gone.
- Future Function() _feeFetcher(
+ Future Function() _feeFetcher(
ProviderReader read,
String recipient,
BigInt amount, {
@@ -102,6 +102,10 @@ class RegularSendStrategy extends SendStrategy {
);
}
+ /// Chain fee for a transfer of [amount] to [recipient].
+ Future fetchFee(ProviderReader read, {required String recipient, required BigInt amount}) =>
+ _feeFetcher(read, recipient, amount, sendAll: false)();
+
@override
String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) => null;
@@ -250,19 +254,15 @@ class RegularSendStrategy extends SendStrategy {
);
}
- final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason);
- if (!authed) return SendFailed(l10n.sendReviewAuthRequired);
-
try {
- final hash = await ref
- .read(transactionSubmissionServiceProvider)
- .balanceTransfer(
- account,
- call: _transferCall(ref.read, recipient, amount, sendAll: sendAll),
- targetAddress: recipient,
- amount: amount,
- fee: regularFee.networkFee,
- );
+ final hash = await submitLocal(
+ ref,
+ recipient: recipient,
+ amount: amount,
+ networkFee: regularFee.networkFee,
+ sendAll: sendAll,
+ );
+ if (hash == null) return SendFailed(l10n.sendReviewAuthRequired);
unawaited(
RecentAddressesService()
.addAddress(recipient)
@@ -274,4 +274,27 @@ class RegularSendStrategy extends SendStrategy {
return SendFailed(l10n.sendReviewSubmitFailed);
}
}
+
+ /// Authenticates the user and submits a transfer signed with the local key.
+ /// Returns the extrinsic hash, or null when authentication was declined.
+ Future submitLocal(
+ WidgetRef ref, {
+ required String recipient,
+ required BigInt amount,
+ required BigInt networkFee,
+ bool sendAll = false,
+ }) async {
+ if (account.signsWithHardware) throw StateError('Account ${account.accountId} signs with hardware');
+ final authed = await LocalAuthService().authenticate(localizedReason: ref.read(l10nProvider).sendReviewAuthReason);
+ if (!authed) return null;
+ return ref
+ .read(transactionSubmissionServiceProvider)
+ .balanceTransfer(
+ account,
+ call: _transferCall(ref.read, recipient, amount, sendAll: sendAll),
+ targetAddress: recipient,
+ amount: amount,
+ fee: networkFee,
+ );
+ }
}
diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/deposit_screen.dart
deleted file mode 100644
index e6b1221d6..000000000
--- a/mobile-app/lib/v2/screens/swap/deposit_screen.dart
+++ /dev/null
@@ -1,291 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:qr_flutter/qr_flutter.dart';
-import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase;
-import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
-import 'package:resonance_network_wallet/providers/l10n_provider.dart';
-import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart';
-import 'package:resonance_network_wallet/shared/utils/share_utils.dart';
-import 'package:resonance_network_wallet/v2/components/success_check.dart';
-import 'package:resonance_network_wallet/v2/components/token_icon.dart';
-import 'package:resonance_network_wallet/l10n/app_localizations.dart';
-import 'package:resonance_network_wallet/shared/utils/print.dart';
-
-class DepositScreen extends ConsumerStatefulWidget {
- final SwapOrder order;
- const DepositScreen({super.key, required this.order});
-
- @override
- ConsumerState createState() => _DepositScreenState();
-}
-
-class _DepositScreenState extends ConsumerState {
- final _swapService = SwapService();
- late SwapOrder _order;
- bool _confirming = false;
-
- /// This swap flow is demo-only — no real deposit is ever expected. To make it
- /// impossible to accidentally send funds, the QR code, the on-screen address,
- /// and the copy/share actions all surface this warning instead of the real
- /// deposit address. Scanning the QR decodes to this plain text, not an
- /// address, so no wallet can act on it.
- static const String _demoWarningPayload = 'demo only - do not send funds';
-
- @override
- void initState() {
- super.initState();
- _order = widget.order;
- }
-
- Future _confirmSent() async {
- setState(() => _confirming = true);
- try {
- final updated = await _swapService.confirmFundsSent(_order.orderId);
- if (!mounted) return;
- setState(() {
- _order = updated;
- _confirming = false;
- });
- _pollStatus();
- } catch (e) {
- quantusPrint('Confirm funds sent failed: $e');
- setState(() => _confirming = false);
- }
- }
-
- Future _pollStatus() async {
- while (mounted && _order.status == SwapStatus.processing) {
- await Future.delayed(const Duration(seconds: 2));
- if (!mounted) return;
- try {
- final updated = await _swapService.getSwapStatus(_order.orderId);
- if (!mounted) return;
- setState(() => _order = updated);
- } catch (e) {
- quantusPrint('Swap status poll failed: $e');
- }
- }
- }
-
- void _copyAddress(AppLocalizations l10n) {
- // Demo-only: never expose the real deposit address (see _demoWarningPayload).
- context.copyTextWithToaster(_demoWarningPayload);
- }
-
- @override
- Widget build(BuildContext context) {
- final l10n = ref.watch(l10nProvider);
- final colors = context.colorsV3;
- final text = context.themeTextV3;
- final quote = _order.quote;
- final usd = quote.fromAmount * _swapService.getUsdPrice(quote.fromToken);
-
- return ScaffoldBase(
- appBar: V2AppBar(
- title: l10n.swapTitle,
- trailing: Icon(Icons.info_outline, color: colors.textContent, size: 24),
- ),
- mainContent: Column(
- children: [
- if (_order.status == SwapStatus.complete)
- _completedBody(l10n, colors, text)
- else if (_order.status == SwapStatus.processing)
- _processingBody(l10n, colors, text)
- else
- _depositBody(l10n, colors, text, quote, usd),
- const Spacer(),
- if (_order.status == SwapStatus.depositing) _sentButton(l10n),
- if (_order.status == SwapStatus.complete) _doneButton(l10n),
- const SizedBox(height: 24),
- ],
- ),
- );
- }
-
- Widget _depositBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapQuote quote, double usd) {
- return Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(l10n.swapDepositAmount, style: text.body.copyWith(color: colors.textContent)),
- const SizedBox(width: 6),
- GestureDetector(
- onTap: () => context.copyTextWithToaster(
- SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken),
- message: l10n.swapDepositAmountCopied,
- ),
- child: Container(
- width: 20,
- height: 20,
- decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.xsBorder),
- child: Center(child: Icon(Icons.copy, color: colors.textContent, size: 12)),
- ),
- ),
- ],
- ),
- const SizedBox(height: 14),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- TokenIcon(token: quote.fromToken, size: 28, networkBadgeSize: 11),
- const SizedBox(width: 8),
- Text(
- SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken),
- style: text.amountHero.copyWith(color: colors.textContent),
- ),
- ],
- ),
- const SizedBox(height: 8),
- Text('\$${usd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)),
- const SizedBox(height: 40),
- ClipRRect(
- borderRadius: context.radiusV3.smBorder,
- child: Container(
- color: colors.textWhite,
- padding: const EdgeInsets.all(8),
- child: QrImageView(data: _demoWarningPayload, version: QrVersions.auto, size: 184),
- ),
- ),
- const SizedBox(height: 16),
- // SizedBox(
- // width: 264,
- // child: Stack(
- // children: [
- // Text(
- // _demoWarningPayload,
- // style: text.smallParagraph?.copyWith(
- // color: colors.textPrimary,
- // fontWeight: FontWeight.w500,
- // height: 1.35,
- // ),
- // textAlign: TextAlign.center,
- // ),
- // Positioned(
- // right: 0,
- // top: 19,
- // child: GestureDetector(
- // onTap: () => _copyAddress(l10n),
- // child: Container(
- // width: 20,
- // height: 20,
- // decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(4)),
- // child: Center(child: Icon(Icons.copy, color: colors.textPrimary, size: 12)),
- // ),
- // ),
- // ),
- // ],
- // ),
- // ),
- const SizedBox(height: 16),
- Text(
- l10n.swapDepositDemoWarning,
- style: text.bodyEmphasis.copyWith(color: colors.accentFlare),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 24),
- Row(
- children: [
- Expanded(
- child: QuantusButton.simple(
- label: l10n.receiveCopy,
- variant: ButtonVariant.staged,
- onTap: () => _copyAddress(l10n),
- icon: Icon(Icons.copy, color: colors.textContent, size: 20),
- iconPlacement: IconPlacement.leading,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
- ),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: QuantusButton.simple(
- label: l10n.swapDepositShareQr,
- icon: Icon(Icons.qr_code, color: colors.textContent, size: 20),
- iconPlacement: IconPlacement.leading,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
- variant: ButtonVariant.staged,
- onTap: () {
- shareText(
- context,
- l10n.swapDepositShareContent(
- _order.quote.fromToken.network,
- _order.quote.fromToken.symbol,
- _demoWarningPayload,
- ),
- );
- },
- ),
- ),
- ],
- ),
- const SizedBox(height: 40),
- Text(
- l10n.swapDepositNotice(quote.fromToken.symbol, quote.fromToken.network),
- style: text.caption.copyWith(color: colors.textMuted),
- textAlign: TextAlign.center,
- ),
- ],
- );
- }
-
- Widget _processingBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- return Column(
- children: [
- const SizedBox(height: 80),
- Loader(color: colors.semanticSage),
- const SizedBox(height: 32),
- Text(l10n.swapDepositProcessingTitle, style: text.titleScreen.copyWith(color: colors.textContent)),
- const SizedBox(height: 12),
- Text(l10n.swapDepositProcessingBody, style: text.bodyLarge.copyWith(color: colors.textMuted)),
- ],
- );
- }
-
- Widget _completedBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- final amount = SwapService.formatTokenAmount(_order.quote.toAmount, _order.quote.toToken);
-
- return Column(
- children: [
- const SizedBox(height: 80),
- const SuccessCheck(),
- const SizedBox(height: 32),
- Text(l10n.swapDepositCompleteTitle, style: text.titleSuccess.copyWith(color: colors.textContent)),
- const SizedBox(height: 12),
- Text(
- l10n.swapDepositCompleteBody(amount, AppConstants.tokenSymbol),
- style: text.bodyLarge.copyWith(color: colors.textMuted),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 40),
- Text(
- l10n.swapDemoOnly,
- style: text.titleHero.copyWith(color: colors.accentFlare),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 8),
- Text(
- l10n.swapDemoOnlyBody,
- style: text.bodyLarge.copyWith(color: colors.textMuted),
- textAlign: TextAlign.center,
- ),
- ],
- );
- }
-
- Widget _sentButton(AppLocalizations l10n) {
- return QuantusButton.simple(
- label: l10n.swapDepositSentFunds,
- onTap: _confirmSent,
- variant: ButtonVariant.staged,
- isLoading: _confirming,
- );
- }
-
- Widget _doneButton(AppLocalizations l10n) {
- return QuantusButton.simple(
- label: l10n.swapDepositDone,
- onTap: () => Navigator.popUntil(context, (r) => r.isFirst),
- variant: ButtonVariant.staged,
- );
- }
-}
diff --git a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart
deleted file mode 100644
index d877cb452..000000000
--- a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart
+++ /dev/null
@@ -1,147 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:quantus_sdk/quantus_sdk.dart';
-import 'package:resonance_network_wallet/providers/l10n_provider.dart';
-import 'package:resonance_network_wallet/v2/components/token_icon.dart';
-import 'package:resonance_network_wallet/v2/screens/swap/deposit_screen.dart';
-import 'package:resonance_network_wallet/l10n/app_localizations.dart';
-
-void showReviewQuoteSheet(BuildContext context, SwapQuote quote, String refundAddress) {
- BottomSheetContainer.show(
- context,
- builder: (_) => _ReviewQuoteContent(quote: quote, refundAddress: refundAddress),
- );
-}
-
-class _ReviewQuoteContent extends ConsumerWidget {
- final SwapQuote quote;
- final String refundAddress;
- const _ReviewQuoteContent({required this.quote, required this.refundAddress});
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final l10n = ref.watch(l10nProvider);
- final colors = context.colorsV3;
- final text = context.themeTextV3;
- final swapService = SwapService();
- final fromUsd = quote.fromAmount * swapService.getUsdPrice(quote.fromToken);
- final toUsd = quote.toAmount * swapService.getUsdPrice(quote.toToken);
-
- return BottomSheetContainer(
- title: l10n.swapReviewTitle,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _swapVisual(context, colors, text, fromUsd, toUsd),
- const SizedBox(height: 48),
- _feeRow(
- l10n.swapReviewTotalFees,
- '${SwapService.formatTokenAmount(quote.networkFee, quote.fromToken)} ${quote.fromToken.symbol}',
- colors,
- text,
- ),
- const Padding(padding: EdgeInsets.symmetric(vertical: 16), child: MenuDivider()),
- _feeRow(
- l10n.swapReviewTotalAmount,
- '${SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken)} ${quote.fromToken.symbol}',
- colors,
- text,
- highlight: true,
- ),
- const SizedBox(height: 24),
- Text(
- l10n.swapReviewSlippageWarning(
- (quote.fromAmount * quote.slippageTolerance).toStringAsFixed(2),
- (quote.slippageTolerance * 100).toStringAsFixed(0),
- ),
- style: text.caption.copyWith(color: colors.textMuted),
- ),
- const SizedBox(height: 24),
- _confirmButton(context, l10n),
- ],
- ),
- );
- }
-
- Widget _swapVisual(BuildContext context, AppColorsV3 colors, AppTextThemeV3 text, double fromUsd, double toUsd) {
- final cardWidth = MediaQuery.of(context).size.width / 3;
- return Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- _tokenCard(context, quote.fromToken, quote.fromAmount, fromUsd, cardWidth, colors, text),
- Icon(Icons.arrow_forward, color: colors.textMuted, size: 20),
- _tokenCard(context, quote.toToken, quote.toAmount, toUsd, cardWidth, colors, text),
- ],
- );
- }
-
- Widget _tokenCard(
- BuildContext context,
- SwapToken token,
- double amount,
- double usd,
- double width,
- AppColorsV3 colors,
- AppTextThemeV3 text,
- ) {
- return Container(
- width: width,
- height: 111,
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
- decoration: BoxDecoration(color: colors.bgSurface2, borderRadius: context.radiusV3.mdBorder),
- child: Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- mainAxisSize: MainAxisSize.min,
- children: [
- TokenIcon(token: token, size: 22, networkBadgeSize: 9),
- const SizedBox(width: 8),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(token.symbol, style: text.caption.copyWith(color: colors.textContent)),
- Text(token.network, style: text.caption.copyWith(color: colors.textMuted)),
- ],
- ),
- ],
- ),
- const SizedBox(height: 6),
- FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- SwapService.formatTokenAmount(amount, token),
- style: text.amountRow.copyWith(color: colors.textContent),
- ),
- ),
- Text('\$${usd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)),
- ],
- ),
- );
- }
-
- Widget _feeRow(String label, String value, AppColorsV3 colors, AppTextThemeV3 text, {bool highlight = false}) {
- return Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(label, style: text.caption.copyWith(color: colors.textMuted)),
- Text(value, style: text.body.copyWith(color: highlight ? colors.textContent : colors.textMuted)),
- ],
- );
- }
-
- Widget _confirmButton(BuildContext context, AppLocalizations l10n) {
- return QuantusButton.simple(
- label: l10n.swapReviewConfirm,
- variant: ButtonVariant.staged,
- onTap: () async {
- final swapService = SwapService();
- final order = await swapService.createSwap(quote);
- if (!context.mounted) return;
- Navigator.pop(context);
- Navigator.push(context, MaterialPageRoute(builder: (_) => DepositScreen(order: order)));
- },
- );
- }
-}
diff --git a/mobile-app/lib/v2/screens/swap/review_swap_screen.dart b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart
new file mode 100644
index 000000000..55dbde71e
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart
@@ -0,0 +1,200 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase;
+import 'package:resonance_network_wallet/l10n/app_localizations.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/providers/wallet_providers.dart';
+import 'package:resonance_network_wallet/shared/utils/print.dart';
+import 'package:resonance_network_wallet/v2/components/link_button.dart';
+import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
+import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart';
+import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_progress_screen.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_summary.dart';
+
+/// Shows a dry quote's terms. Confirming takes a live quote; when its
+/// guaranteed minimum is below the one shown, the live terms replace the shown
+/// ones and need a second confirmation. A swap out of QTC then sends the
+/// deposit from [account].
+class ReviewSwapScreen extends ConsumerStatefulWidget {
+ final Account account;
+ final SwapQuote quote;
+
+ const ReviewSwapScreen({super.key, required this.account, required this.quote});
+
+ @override
+ ConsumerState createState() => _ReviewSwapScreenState();
+}
+
+class _ReviewSwapScreenState extends ConsumerState {
+ late SwapQuote _quote = widget.quote;
+ SwapOrder? _order;
+ bool _confirming = false;
+
+ bool get _swapOut => _quote.fromToken.isQuantus;
+
+ (Account, BigInt) get _feeKey => (widget.account, _quote.amountIn);
+
+ Future _confirm() async {
+ final l10n = ref.read(l10nProvider);
+ setState(() => _confirming = true);
+ try {
+ final order = await _liveOrder(l10n);
+ if (order == null || !mounted) return;
+ final fee = _swapOut ? ref.read(swapDepositFeeProvider(_feeKey)).requireValue : null;
+ final txHash = fee != null ? await _sendDeposit(order, fee, l10n) : null;
+ if ((fee != null && txHash == null) || !mounted) return;
+ Navigator.pushAndRemoveUntil(
+ context,
+ MaterialPageRoute(
+ builder: (_) =>
+ SwapProgressScreen(account: widget.account, order: order, depositTxHash: txHash, depositFee: fee),
+ ),
+ (route) => route.isFirst,
+ );
+ } finally {
+ if (mounted) setState(() => _confirming = false);
+ }
+ }
+
+ /// A live order at least as good as the terms on screen, or null when the
+ /// terms on screen changed. A live order is kept until close to its deadline,
+ /// so a retry reuses its deposit address.
+ Future _liveOrder(AppLocalizations l10n) async {
+ final kept = _order;
+ if (kept != null && kept.quote.deadline.isAfter(DateTime.now().add(SwapService.minimumDepositLead))) return kept;
+ try {
+ final order = await ref.read(swapServiceProvider).createSwap(_quote);
+ _order = order;
+ if (order.quote.minAmountOut >= _quote.minAmountOut) return order;
+ if (mounted) {
+ setState(() => _quote = order.quote);
+ context.showWarningToaster(message: l10n.swapReviewPriceMoved);
+ }
+ } catch (e) {
+ quantusPrint('Swap create failed: $e');
+ if (mounted) context.showErrorToaster(message: l10n.swapQuoteError(describeSwapError(e)));
+ }
+ return null;
+ }
+
+ /// Sends the quoted QTC into the deposit address; null when nothing was sent.
+ Future _sendDeposit(SwapOrder order, BigInt fee, AppLocalizations l10n) async {
+ try {
+ final hash = await RegularSendStrategy(
+ account: widget.account,
+ ).submitLocal(ref, recipient: order.depositAddress, amount: order.quote.amountIn, networkFee: fee);
+ if (hash == null) {
+ if (mounted) context.showErrorToaster(message: l10n.sendReviewAuthRequired);
+ return null;
+ }
+ unawaited(
+ ref
+ .read(swapServiceProvider)
+ .submitDeposit(order, hash)
+ .catchError((Object e) => quantusPrint('Swap deposit submit failed: $e')),
+ );
+ return hash;
+ } catch (e) {
+ quantusPrint('Swap deposit transfer failed: $e');
+ if (mounted) context.showErrorToaster(message: l10n.sendReviewSubmitFailed);
+ return null;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final quote = _quote;
+ final from = quote.fromToken;
+ final to = quote.toToken;
+ String amount(BigInt value, SwapToken token) => formatSwapAmount(l10n, fmt, value, token);
+ Widget row(String label, String value, {bool mono = false}) =>
+ SwapDetailRow(label: label, value: value, monoLabel: true, monoValue: mono);
+
+ final fee = _swapOut ? ref.watch(swapDepositFeeProvider(_feeKey)) : null;
+ final spendable = _swapOut ? ref.watch(effectiveMaxBalanceProviderFamily(widget.account.accountId)).value : null;
+ final feeValue = fee?.value;
+ final insufficient = feeValue != null && spendable != null && quote.amountIn + feeValue > spendable;
+ final blocked = _swapOut && (feeValue == null || spendable == null || insufficient);
+
+ return ScaffoldBase(
+ appBar: V2AppBar(title: l10n.swapReviewTitle),
+ mainContent: SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 24),
+ SwapAmountsCard(quote: quote, payLabel: l10n.swapYouPay, receiveLabel: l10n.swapYouReceive),
+ const SizedBox(height: 32),
+ row(
+ l10n.swapRate,
+ l10n.swapRateLabel(from.symbol, fmt.formatAmount(swapQuoteRate(quote), decimals: to.decimals), to.symbol),
+ ),
+ if (_swapOut)
+ row(l10n.swapReviewRecipient, AddressFormattingService.formatAddress(quote.recipient), mono: true)
+ else
+ row(
+ l10n.swapReviewRefundAddress,
+ AddressFormattingService.formatAddress(quote.refundAddress),
+ mono: true,
+ ),
+ if (fee != null)
+ row(
+ l10n.swapReviewNetworkFee,
+ fee.hasError ? l10n.swapReviewFeeUnavailable : (feeValue == null ? '…' : amount(feeValue, from)),
+ ),
+ row(
+ l10n.swapReviewSlippage,
+ l10n.swapReviewSlippageValue(
+ amount(quote.amountOut - quote.minAmountOut, to),
+ slippagePercentLabel(quote.slippageBps),
+ ),
+ ),
+ row(l10n.swapReviewGuaranteedMinimum, amount(quote.minAmountOut, to)),
+ if (fee != null && fee.hasError) ...[
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: Text(l10n.swapReviewFeeFailed, style: text.caption.copyWith(color: colors.semanticEmber)),
+ ),
+ LinkButton(label: l10n.commonRetry, onTap: () => ref.invalidate(swapDepositFeeProvider(_feeKey))),
+ ],
+ ),
+ ],
+ if (insufficient) ...[
+ const SizedBox(height: 16),
+ Text(l10n.swapReviewInsufficient(from.symbol), style: text.caption.copyWith(color: colors.semanticEmber)),
+ ],
+ ],
+ ),
+ ),
+ bottomContent: ScaffoldBaseBottomContent(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ QuantusButton.simple(
+ label: l10n.swapReviewConfirm,
+ onTap: _confirm,
+ isLoading: _confirming,
+ isDisabled: blocked,
+ ),
+ const SizedBox(height: 4),
+ QuantusButton.simple(
+ label: l10n.commonCancel,
+ variant: ButtonVariant.underline,
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart
similarity index 67%
rename from mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart
rename to mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart
index 0d6be26ae..4a1a334c4 100644
--- a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart
+++ b/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart
@@ -2,20 +2,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
-Future showRefundAddressPickerSheet(BuildContext context, String network) {
- return BottomSheetContainer.show(context, builder: (_) => _RefundAddressPickerContent(network: network));
+/// Addresses saved for [token]'s network; pops the one picked.
+Future showSavedAddressPickerSheet(BuildContext context, SwapToken token) {
+ return BottomSheetContainer.show(context, builder: (_) => _SavedAddressPickerContent(token: token));
}
-class _RefundAddressPickerContent extends ConsumerStatefulWidget {
- final String network;
- const _RefundAddressPickerContent({required this.network});
+class _SavedAddressPickerContent extends ConsumerStatefulWidget {
+ final SwapToken token;
+ const _SavedAddressPickerContent({required this.token});
@override
- ConsumerState<_RefundAddressPickerContent> createState() => _RefundAddressPickerContentState();
+ ConsumerState<_SavedAddressPickerContent> createState() => _SavedAddressPickerContentState();
}
-class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPickerContent> {
+class _SavedAddressPickerContentState extends ConsumerState<_SavedAddressPickerContent> {
List _addresses = [];
@override
@@ -25,7 +27,7 @@ class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPicke
}
Future _load() async {
- final addresses = await SwapService().getRefundAddresses(widget.network);
+ final addresses = await ref.read(swapServiceProvider).getSavedAddresses(widget.token.network);
if (mounted) setState(() => _addresses = addresses);
}
@@ -36,19 +38,19 @@ class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPicke
final text = context.themeTextV3;
return BottomSheetContainer(
- title: l10n.swapRefundPickerTitle,
+ title: l10n.swapSavedAddressesTitle,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
- child: Text(widget.network, style: text.caption.copyWith(color: colors.textMuted)),
+ child: Text(widget.token.networkName, style: text.caption.copyWith(color: colors.textMuted)),
),
const SizedBox(height: 24),
if (_addresses.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
- child: Text(l10n.swapRefundPickerEmpty, style: text.caption.copyWith(color: colors.textMuted)),
+ child: Text(l10n.swapSavedAddressesEmpty, style: text.caption.copyWith(color: colors.textMuted)),
)
else
ConstrainedBox(
diff --git a/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart b/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart
new file mode 100644
index 000000000..c1769f65b
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart
@@ -0,0 +1,165 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:quantus_sdk/quantus_sdk.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/shared/utils/print.dart';
+import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart';
+import 'package:resonance_network_wallet/v2/screens/settings/settings_checkbox.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/saved_address_picker_sheet.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+
+/// Whether the external address receives the swapped funds or gets them back.
+enum SwapAddressRole { recipient, refund }
+
+/// Asks for the external address on [token]'s network, then [quote]s the swap
+/// with it. Pops the quote, or null when dismissed.
+Future showSwapAddressSheet(
+ BuildContext context, {
+ required SwapToken token,
+ required SwapAddressRole role,
+ required Future Function(String address) quote,
+}) {
+ return BottomSheetContainer.show(
+ context,
+ builder: (_) => _SwapAddressSheet(token: token, role: role, quote: quote),
+ );
+}
+
+class _SwapAddressSheet extends ConsumerStatefulWidget {
+ final SwapToken token;
+ final SwapAddressRole role;
+ final Future Function(String address) quote;
+
+ const _SwapAddressSheet({required this.token, required this.role, required this.quote});
+
+ @override
+ ConsumerState<_SwapAddressSheet> createState() => _SwapAddressSheetState();
+}
+
+class _SwapAddressSheetState extends ConsumerState<_SwapAddressSheet> {
+ static const _addressBookAsset = 'assets/v2/swap_address_book.svg';
+ static const _qrAsset = 'assets/v2/swap_qr_code.svg';
+ static const _iconButtonSize = 32.0;
+
+ final _controller = TextEditingController();
+ bool _save = false;
+ bool _quoting = false;
+
+ String get _address => _controller.text.trim();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ Future _continue() async {
+ final navigator = Navigator.of(context);
+ final address = _address;
+ setState(() => _quoting = true);
+ try {
+ final quote = await widget.quote(address);
+ if (_save) await ref.read(swapServiceProvider).saveAddress(widget.token.network, address);
+ navigator.pop(quote);
+ } catch (e) {
+ quantusPrint('Swap quote failed: $e');
+ if (!mounted) return;
+ context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e)));
+ } finally {
+ if (mounted) setState(() => _quoting = false);
+ }
+ }
+
+ Future _fill(Future source) async {
+ final address = await source;
+ if (address != null && mounted) setState(() => _controller.text = address);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final token = widget.token;
+ final recipient = widget.role == SwapAddressRole.recipient;
+
+ return BottomSheetContainer(
+ title: recipient ? l10n.swapRecipientAddressTitle : l10n.swapRefundAddressTitle,
+ trailing: QuantusIconButton.ghost(
+ icon: Icons.close,
+ onTap: () => Navigator.pop(context),
+ size: IconButtonSize.small,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 16),
+ Text(l10n.swapAddressLabel(token.symbol), style: text.headingRow.copyWith(color: colors.textContent)),
+ const SizedBox(height: 12),
+ QuantusTextField(
+ controller: _controller,
+ hint: l10n.swapAddressHint(token.networkName),
+ autocorrect: false,
+ enableSuggestions: false,
+ onChanged: (_) => setState(() {}),
+ trailingWidth: _iconButtonSize * 2 + 12,
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _iconButton(_addressBookAsset, () => _fill(showSavedAddressPickerSheet(context, token))),
+ const SizedBox(width: 8),
+ _iconButton(
+ _qrAsset,
+ () =>
+ _fill(Navigator.push(context, MaterialPageRoute(builder: (_) => const QrScannerPage()))),
+ ),
+ const SizedBox(width: 4),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ recipient
+ ? l10n.swapRecipientAddressNotice(token.symbol)
+ : l10n.swapRefundAddressNotice(token.symbol, token.networkName),
+ style: text.body.copyWith(color: colors.textMuted),
+ ),
+ const SizedBox(height: 12),
+ SettingsCheckbox(checked: _save, label: l10n.swapSaveAddress, onTap: () => setState(() => _save = !_save)),
+ const SizedBox(height: 32),
+ QuantusButton.simple(
+ label: l10n.swapContinue,
+ onTap: _continue,
+ isDisabled: _address.isEmpty,
+ isLoading: _quoting,
+ ),
+ const SizedBox(height: 4),
+ QuantusButton.simple(
+ label: l10n.commonCancel,
+ variant: ButtonVariant.underline,
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _iconButton(String asset, VoidCallback onTap) {
+ final colors = context.colorsV3;
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: _iconButtonSize,
+ height: _iconButtonSize,
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: colors.bgVoid,
+ borderRadius: context.radiusV3.smBorder,
+ border: Border.all(color: colors.borderHairline),
+ ),
+ child: SvgPicture.asset(asset, colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn)),
+ ),
+ );
+ }
+}
diff --git a/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart
new file mode 100644
index 000000000..ae249b1ff
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart
@@ -0,0 +1,505 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:qr_flutter/qr_flutter.dart';
+import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase;
+import 'package:resonance_network_wallet/l10n/app_localizations.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/providers/wallet_providers.dart';
+import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart';
+import 'package:resonance_network_wallet/shared/utils/open_external_url.dart';
+import 'package:resonance_network_wallet/shared/utils/share_utils.dart';
+import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
+import 'package:resonance_network_wallet/v2/components/success_check.dart';
+import 'package:resonance_network_wallet/v2/components/token_icon.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_summary.dart';
+
+/// Follows a live swap through 1Click's status endpoint: the deposit address
+/// while a swap into QTC waits for its deposit, then progress, and finally
+/// the completed or failed swap.
+class SwapProgressScreen extends ConsumerWidget {
+ final Account account;
+ final SwapOrder order;
+
+ /// Hash and fee of the QTC transfer that paid a swap out of QTC.
+ final String? depositTxHash;
+ final BigInt? depositFee;
+
+ const SwapProgressScreen({
+ super.key,
+ required this.account,
+ required this.order,
+ this.depositTxHash,
+ this.depositFee,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final current = ref.watch(swapOrderProvider(order)).value ?? order;
+ final awaitingDeposit = current.status == SwapStatus.pendingDeposit && !current.quote.fromToken.isQuantus;
+ final expired = awaitingDeposit && DateTime.now().isAfter(current.quote.deadline);
+ return switch (current.status) {
+ SwapStatus.success => _SwapComplete(order: current),
+ SwapStatus.refunded ||
+ SwapStatus.failed ||
+ SwapStatus.incompleteDeposit => _SwapFailed(order: current, account: account),
+ _ when expired => _SwapFailed(order: current, account: account, expired: true),
+ _ when awaitingDeposit => _SwapDeposit(order: current),
+ _ => _SwapInProgress(order: current, account: account, depositTxHash: depositTxHash, depositFee: depositFee),
+ };
+ }
+}
+
+class _SwapInProgress extends ConsumerWidget {
+ static const _listAsset = 'assets/v2/swap_list.svg';
+
+ final SwapOrder order;
+ final Account account;
+ final String? depositTxHash;
+ final BigInt? depositFee;
+
+ const _SwapInProgress({required this.order, required this.account, this.depositTxHash, this.depositFee});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final quote = order.quote;
+ final from = quote.fromToken;
+ final to = quote.toToken;
+ final receiver = from.isQuantus ? AddressFormattingService.formatAddress(quote.recipient) : account.name;
+ final steps = [
+ l10n.swapStepSent(from.symbol),
+ l10n.swapStepConfirming(from.networkName),
+ l10n.swapStepSwapping,
+ l10n.swapStepSending(to.symbol, receiver),
+ ];
+ final stage = order.status == SwapStatus.processing ? 2 : 1;
+
+ return ScaffoldBase(
+ appBar: V2AppBar(
+ title: l10n.swapInProgressTitle,
+ trailing: GestureDetector(
+ onTap: () => BottomSheetContainer.show(
+ context,
+ builder: (_) => _SwapDetailsSheet(order: order, depositTxHash: depositTxHash, depositFee: depositFee),
+ ),
+ child: SvgPicture.asset(
+ _listAsset,
+ width: 20,
+ height: 20,
+ colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn),
+ ),
+ ),
+ ),
+ mainContent: SingleChildScrollView(
+ child: Column(
+ children: [
+ const SizedBox(height: 24),
+ SwapAmountsCard(quote: quote, payLabel: l10n.swapYourePaying, receiveLabel: l10n.swapYoureReceiving),
+ const SizedBox(height: 36),
+ for (final (i, label) in steps.indexed)
+ QuantusStepRow(
+ state: i < stage
+ ? StepRowState.done
+ : i == stage
+ ? StepRowState.active
+ : StepRowState.pending,
+ label: label,
+ ),
+ ],
+ ),
+ ),
+ bottomContent: ScaffoldBaseBottomContent(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ LinearProgressIndicator(
+ value: (stage + 0.5) / steps.length,
+ minHeight: 4,
+ backgroundColor: colors.bgSurface2,
+ color: colors.accentFlare,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ const SizedBox(height: 32),
+ Text(
+ l10n.swapProgressFooter,
+ textAlign: TextAlign.center,
+ style: text.body.copyWith(color: colors.textMuted2),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SwapDetailsSheet extends ConsumerWidget {
+ final SwapOrder order;
+ final String? depositTxHash;
+ final BigInt? depositFee;
+
+ const _SwapDetailsSheet({required this.order, this.depositTxHash, this.depositFee});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final quote = order.quote;
+ final fee = depositFee;
+ final tx = depositTxHash ?? order.originTxHashes.firstOrNull;
+
+ return BottomSheetContainer(
+ title: l10n.swapDetailsTitle,
+ trailing: QuantusIconButton.ghost(
+ icon: Icons.close,
+ onTap: () => Navigator.pop(context),
+ size: IconButtonSize.small,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ SwapDetailRow(
+ label: l10n.swapDetailsRecipient,
+ value: AddressFormattingService.formatAddress(quote.recipient),
+ monoValue: true,
+ ),
+ SwapDetailRow(
+ label: l10n.swapDetailsAmountSent,
+ value: formatSwapAmount(l10n, fmt, quote.amountIn, quote.fromToken, exact: true),
+ ),
+ if (fee != null)
+ SwapDetailRow(label: l10n.swapReviewNetworkFee, value: formatSwapAmount(l10n, fmt, fee, quote.fromToken)),
+ SwapDetailRow(
+ label: l10n.swapDetailsExpected,
+ value: formatSwapAmount(l10n, fmt, quote.amountOut, quote.toToken),
+ ),
+ if (tx != null) ...[
+ const MenuDivider(),
+ SwapDetailRow(
+ label: l10n.swapDetailsTransaction,
+ value: AddressFormattingService.formatAddress(tx),
+ monoValue: true,
+ ),
+ ],
+ const SizedBox(height: 32),
+ QuantusButton.simple(
+ label: l10n.commonDone,
+ variant: ButtonVariant.staged,
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _SwapComplete extends ConsumerWidget {
+ final SwapOrder order;
+
+ const _SwapComplete({required this.order});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final quote = order.quote;
+ final to = quote.toToken;
+
+ return ScaffoldBase(
+ appBar: V2AppBar(title: l10n.swapCompleteTitle),
+ mainContent: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SuccessCheck(size: 80),
+ const SizedBox(height: 40),
+ Text(
+ formatSwapAmount(l10n, fmt, order.amountOut ?? quote.amountOut, to),
+ style: text.titleSuccess.copyWith(color: colors.textContent),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(l10n.swapCompleteReceived, style: text.caption.copyWith(color: colors.textMuted)),
+ const SizedBox(height: 48),
+ Text(l10n.swapCompleteTo, style: text.bodyEmphasis.copyWith(color: colors.textContent)),
+ const SizedBox(height: 16),
+ Text(to.networkName, style: text.body.copyWith(color: colors.accentFlare)),
+ const SizedBox(height: 4),
+ Text(
+ AddressFormattingService.formatAddress(quote.recipient),
+ style: text.dataAddress.copyWith(color: colors.textContent),
+ ),
+ ],
+ ),
+ ),
+ bottomContent: ScaffoldBaseBottomContent(
+ child: QuantusButton.simple(
+ label: l10n.commonDone,
+ onTap: () => Navigator.popUntil(context, (route) => route.isFirst),
+ ),
+ ),
+ );
+ }
+}
+
+/// A swap that ended without paying out: refunded, failed, short-paid, or a
+/// deposit window that closed with nothing sent.
+class _SwapFailed extends ConsumerWidget {
+ final SwapOrder order;
+ final Account account;
+ final bool expired;
+
+ const _SwapFailed({required this.order, required this.account, this.expired = false});
+
+ String _message(AppLocalizations l10n, NumberFormattingService fmt) {
+ final quote = order.quote;
+ final from = quote.fromToken;
+ if (expired) return l10n.swapDepositExpiredBody;
+ return switch (order.status) {
+ SwapStatus.refunded => [
+ l10n.swapRefundedBody(
+ formatSwapAmount(l10n, fmt, order.refundedAmount ?? quote.amountIn, from),
+ from.isQuantus ? account.name : AddressFormattingService.formatAddress(quote.refundAddress),
+ quote.toToken.symbol,
+ ),
+ if (order.refundReason != null) l10n.swapDepositRefundReason(order.refundReason!),
+ ].join('\n\n'),
+ SwapStatus.incompleteDeposit => l10n.swapDepositIncompleteBody,
+ _ => l10n.swapDepositFailedBody(order.depositAddress),
+ };
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final refunded = order.status == SwapStatus.refunded;
+ final warning = expired || order.status == SwapStatus.incompleteDeposit;
+
+ return ScaffoldBase(
+ appBar: V2AppBar(
+ title: expired
+ ? l10n.swapDepositExpiredTitle
+ : order.status == SwapStatus.incompleteDeposit
+ ? l10n.swapDepositIncompleteTitle
+ : l10n.swapFailedTitle,
+ ),
+ mainContent: SingleChildScrollView(
+ child: Column(
+ children: [
+ const SizedBox(height: 24),
+ if (refunded) ...[
+ Text(
+ l10n.swapRefundedLabel.toUpperCase(),
+ style: text.labelMonogram.copyWith(color: colors.semanticSand),
+ ),
+ const SizedBox(height: 12),
+ ],
+ if (!expired) ...[
+ SwapAmountsCard(
+ quote: order.quote,
+ payLabel: l10n.swapYouPaid,
+ receiveLabel: l10n.swapYouWereReceiving,
+ amountOut: order.amountOut,
+ receiveDimmed: true,
+ ),
+ const SizedBox(height: 32),
+ ],
+ QuantusBanner(tone: warning ? BannerTone.sand : BannerTone.ember, message: _message(l10n, fmt)),
+ ],
+ ),
+ ),
+ bottomContent: ScaffoldBaseBottomContent(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ QuantusButton.simple(
+ label: l10n.swapStartNew,
+ onTap: () => Navigator.pushAndRemoveUntil(
+ context,
+ MaterialPageRoute(builder: (_) => SwapScreen(account: account)),
+ (route) => route.isFirst,
+ ),
+ ),
+ const SizedBox(height: 4),
+ QuantusButton.simple(
+ label: l10n.swapContactSupport,
+ variant: ButtonVariant.underline,
+ onTap: () => openUrl(AppConstants.techSupportUrl),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Where to send the external token for a swap into QTC.
+class _SwapDeposit extends ConsumerWidget {
+ final SwapOrder order;
+
+ const _SwapDeposit({required this.order});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final quote = order.quote;
+ final from = quote.fromToken;
+ final address = order.depositAddress;
+ final memo = quote.depositMemo;
+ final deadline = TimeOfDay.fromDateTime(quote.deadline.toLocal()).format(context);
+ Future copyAddress() => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied);
+ final shareContent = memo == null
+ ? l10n.swapDepositShareContent(from.networkName, from.symbol, address)
+ : l10n.swapDepositShareContentMemo(from.networkName, from.symbol, address, memo);
+
+ return ScaffoldBase(
+ appBar: V2AppBar(title: l10n.swapTitle),
+ mainContent: SingleChildScrollView(
+ child: Column(
+ children: [
+ const SizedBox(height: 24),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(l10n.swapDepositAmount, style: text.body.copyWith(color: colors.textContent)),
+ const SizedBox(width: 6),
+ GestureDetector(
+ onTap: () => context.copyTextWithToaster(
+ fmt.formatWireAmount(quote.amountIn, decimals: from.decimals),
+ message: l10n.swapDepositAmountCopied,
+ ),
+ child: Container(
+ width: 20,
+ height: 20,
+ decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.xsBorder),
+ child: Center(child: Icon(Icons.copy, color: colors.textContent, size: 12)),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 14),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ TokenIcon(token: from, size: 28, networkBadgeSize: 11),
+ const SizedBox(width: 8),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ fmt.formatExactAmount(quote.amountIn, decimals: from.decimals),
+ style: text.amountHero.copyWith(color: colors.textContent),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text('\$${quote.amountInUsd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)),
+ const SizedBox(height: 24),
+ ClipRRect(
+ borderRadius: context.radiusV3.smBorder,
+ child: Container(
+ color: colors.textWhite,
+ padding: const EdgeInsets.all(8),
+ child: QrImageView(data: address, version: QrVersions.auto, size: 184),
+ ),
+ ),
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: copyAddress,
+ child: Text(
+ address,
+ style: text.dataAddress.copyWith(color: colors.textContent),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ if (memo != null) ...[
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () => context.copyTextWithToaster(memo, message: l10n.swapDepositMemoCopied),
+ behavior: HitTestBehavior.opaque,
+ child: Column(
+ children: [
+ Text(
+ l10n.swapDepositMemo.toUpperCase(),
+ style: text.labelMonogram.copyWith(color: colors.textMuted),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ memo,
+ style: text.dataAddressLarge.copyWith(color: colors.textContent),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ QuantusBanner(tone: BannerTone.sand, message: l10n.swapDepositMemoNotice),
+ ],
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Loader(color: colors.semanticSage, size: 14),
+ const SizedBox(width: 8),
+ Text(l10n.swapDepositWaiting, style: text.caption.copyWith(color: colors.textMuted)),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ l10n.swapDepositDeadline(deadline),
+ style: text.bodyEmphasis.copyWith(color: colors.accentFlare),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ Row(
+ children: [
+ Expanded(
+ child: QuantusButton.simple(
+ label: l10n.receiveCopy,
+ variant: ButtonVariant.staged,
+ onTap: copyAddress,
+ icon: Icon(Icons.copy, color: colors.textContent, size: 20),
+ iconPlacement: IconPlacement.leading,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: QuantusButton.simple(
+ label: l10n.swapDepositShareQr,
+ icon: Icon(Icons.qr_code, color: colors.textContent, size: 20),
+ iconPlacement: IconPlacement.leading,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ variant: ButtonVariant.staged,
+ onTap: () => shareText(context, shareContent),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ Text(
+ l10n.swapDepositNotice(from.symbol, from.networkName),
+ style: text.caption.copyWith(color: colors.textMuted),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile-app/lib/v2/screens/swap/swap_providers.dart b/mobile-app/lib/v2/screens/swap/swap_providers.dart
new file mode 100644
index 000000000..65bdfe0ff
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/swap_providers.dart
@@ -0,0 +1,88 @@
+import 'dart:math';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_riverpod/legacy.dart';
+import 'package:quantus_sdk/quantus_sdk.dart';
+import 'package:resonance_network_wallet/l10n/app_localizations.dart';
+import 'package:resonance_network_wallet/providers/currency_display_provider.dart';
+import 'package:resonance_network_wallet/shared/utils/print.dart';
+import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart';
+
+final swapServiceProvider = Provider((_) => SwapService());
+
+/// Slippage tolerances offered for a quote, in basis points.
+const swapSlippageOptionsBps = [50, 100, 200, 300];
+
+/// Slippage tolerance the next quote asks for, in basis points.
+final swapSlippageBpsProvider = StateProvider((_) => SwapService.defaultSlippageBps);
+
+/// QTC from the app's own metadata, priced with the rate the rest of the app
+/// uses. Stands in until 1Click lists QTC; the listing then takes over.
+final quantusSwapTokenProvider = Provider(
+ (ref) => SwapService.quantusToken(usdPrice: ref.watch(exchangeRateServiceProvider).tokenToUsdRate.toDouble()),
+);
+
+/// Chain fee for sending [amount] QTC from the account into a deposit address.
+/// The recipient barely moves the fee, so the account's own address stands in
+/// until the live quote names the deposit address.
+final swapDepositFeeProvider = FutureProvider.autoDispose.family((ref, args) async {
+ final (account, amount) = args;
+ try {
+ return (await RegularSendStrategy(
+ account: account,
+ ).fetchFee(ref.read, recipient: account.accountId, amount: amount)).networkFee;
+ } catch (e) {
+ quantusPrint('Swap deposit fee failed: $e');
+ rethrow;
+ }
+}, retry: (_, _) => null);
+
+/// [order] followed through 1Click's status endpoint until it settles.
+final swapOrderProvider = StreamProvider.autoDispose.family((ref, order) async* {
+ final service = ref.watch(swapServiceProvider);
+ var current = order;
+ yield current;
+ while (!current.status.isFinal) {
+ await Future.delayed(SwapService.statusPollInterval);
+ if (!ref.mounted) return;
+ try {
+ current = await service.getSwapStatus(current);
+ yield current;
+ } catch (e) {
+ quantusPrint('Swap status poll failed: $e');
+ }
+ }
+});
+
+/// USD value of [amount] base units of [token] at its listed price.
+double swapUsdValue(BigInt amount, SwapToken token) => amount.toDouble() / pow(10, token.decimals) * token.usdPrice;
+
+/// Base units of [to] that [amountIn] of [from] is worth at listed prices.
+BigInt swapEstimateOut(BigInt amountIn, SwapToken from, SwapToken to) {
+ if (to.usdPrice <= 0) return BigInt.zero;
+ return BigInt.from(swapUsdValue(amountIn, from) / to.usdPrice * pow(10, to.decimals));
+}
+
+/// Base units of [quote]'s output token that one whole input token buys.
+BigInt swapQuoteRate(SwapQuote quote) =>
+ quote.amountOut * BigInt.from(10).pow(quote.fromToken.decimals) ~/ quote.amountIn;
+
+/// [value] base units of [token] with its symbol, e.g. "24.86 USDC". An
+/// [exact] amount keeps every digit: what is sent must read as what is shown.
+String formatSwapAmount(
+ AppLocalizations l10n,
+ NumberFormattingService fmt,
+ BigInt value,
+ SwapToken token, {
+ bool exact = false,
+}) => l10n.commonAmountBalance(
+ exact ? fmt.formatExactAmount(value, decimals: token.decimals) : fmt.formatAmount(value, decimals: token.decimals),
+ token.symbol,
+);
+
+String describeSwapError(Object error) => error is SwapApiException ? error.message : error.toString();
+
+String slippagePercentLabel(int bps) {
+ final percent = bps / 100;
+ return percent == percent.roundToDouble() ? percent.toStringAsFixed(0) : percent.toString();
+}
diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart
index de1be565f..9efd2a356 100644
--- a/mobile-app/lib/v2/screens/swap/swap_screen.dart
+++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart
@@ -1,121 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
+import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase;
import 'package:resonance_network_wallet/l10n/app_localizations.dart';
import 'package:resonance_network_wallet/providers/l10n_provider.dart';
-import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart';
-import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase;
-import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
+import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/shared/utils/print.dart';
import 'package:resonance_network_wallet/v2/components/near_intents_attribution.dart';
+import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
import 'package:resonance_network_wallet/v2/components/token_icon.dart';
-import 'package:resonance_network_wallet/v2/screens/swap/refund_address_picker_sheet.dart';
-import 'package:resonance_network_wallet/v2/screens/swap/review_quote_sheet.dart';
+import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/review_swap_screen.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_address_sheet.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_slippage_sheet.dart';
import 'package:resonance_network_wallet/v2/screens/swap/token_picker_sheet.dart';
+/// Swaps between QTC in [account] and a token on another chain, either way.
class SwapScreen extends ConsumerStatefulWidget {
- const SwapScreen({super.key});
+ final Account account;
+
+ const SwapScreen({super.key, required this.account});
@override
ConsumerState createState() => _SwapScreenState();
}
class _SwapScreenState extends ConsumerState {
- static const _qrIconAsset = 'assets/v2/swap_qr_code.svg';
- static const _historyIconAsset = 'assets/v2/swap_clock_counter_clockwise.svg';
- static const _swapDirectionIconAsset = 'assets/v2/swap_arrows_down_up.svg';
-
- final _swapService = SwapService();
- final _fromController = TextEditingController();
- final _addressController = TextEditingController();
- SwapToken _fromToken = SwapService.availableTokens.first;
- double _toAmount = 0;
- double _fromUsd = 0;
- double _toUsd = 0;
- bool _loading = false;
+ static const _directionIconAsset = 'assets/v2/swap_arrows_down_up.svg';
+ static const _slippageIconAsset = 'assets/v2/swap_pencil.svg';
+ static const _amountBoxWidth = 125.0;
+ static const _rowHeight = 44.0;
- double get _rate => _swapService.getRate(_fromToken);
-
- String _rateLabel(AppLocalizations l10n) {
- final val = 1 / _rate;
- if (val == 0) return l10n.swapRateZero(_fromToken.symbol, AppConstants.tokenSymbol);
- final decimals = val >= 100
- ? 2
- : val >= 1
- ? 4
- : val >= 0.01
- ? 6
- : val >= 0.0001
- ? 8
- : 10;
- var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), '');
- if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1);
- return l10n.swapRateLabel(formatted, _fromToken.symbol, AppConstants.tokenSymbol);
- }
+ final _amountController = TextEditingController();
+ SwapToken? _foreign;
+ SwapToken? _listedQuantus;
+ bool _loadingTokens = true;
+ bool _swapOut = true;
@override
void initState() {
super.initState();
- _fromController.addListener(_recalculate);
+ _amountController.addListener(() => setState(() {}));
+ _loadTokens();
}
@override
void dispose() {
- _fromController.dispose();
- _addressController.dispose();
+ _amountController.dispose();
super.dispose();
}
- void _recalculate() {
- final amount = double.tryParse(_fromController.text) ?? 0;
- setState(() {
- _toAmount = amount * _rate;
- _fromUsd = amount * _swapService.getUsdPrice(_fromToken);
- _toUsd = _toAmount * _swapService.getUsdPrice(_swapService.getQuToken());
- });
- }
-
- bool get _canGetQuote =>
- _fromController.text.isNotEmpty &&
- (double.tryParse(_fromController.text) ?? 0) > 0 &&
- _addressController.text.isNotEmpty;
-
- Future _getQuote() async {
- final amount = double.tryParse(_fromController.text) ?? 0;
- if (amount <= 0 || _addressController.text.isEmpty) return;
-
- setState(() => _loading = true);
+ Future _loadTokens({bool forceRefresh = false}) async {
+ setState(() => _loadingTokens = true);
try {
- final quote = await _swapService.getQuote(fromToken: _fromToken, fromAmount: amount);
+ final service = ref.read(swapServiceProvider);
+ final tokens = await service.getFromTokens(forceRefresh: forceRefresh);
+ final listedQuantus = await service.getListedQuantusToken();
if (!mounted) return;
- setState(() => _loading = false);
- _swapService.addRefundAddress(_fromToken.network, _addressController.text.trim());
- showReviewQuoteSheet(context, quote, _addressController.text);
+ setState(() {
+ _foreign ??= tokens.first;
+ _listedQuantus = listedQuantus;
+ });
} catch (e) {
- quantusPrint('Swap quote failed: $e');
- setState(() => _loading = false);
+ quantusPrint('Swap tokens failed to load: $e');
+ } finally {
+ if (mounted) setState(() => _loadingTokens = false);
}
}
- void _pickToken() async {
+ BigInt _amountIn(SwapToken from) =>
+ ref.read(numberFormattingServiceProvider).parseAmount(_amountController.text, decimals: from.decimals) ??
+ BigInt.zero;
+
+ void _setDirection({required bool swapOut}) {
+ if (swapOut == _swapOut) return;
+ setState(() => _swapOut = swapOut);
+ _amountController.clear();
+ }
+
+ /// Picks the external token. Picking from the QTC side moves QTC to the other side.
+ Future _pickToken(SwapToken tapped) async {
+ final service = ref.read(swapServiceProvider);
+ final foreign = _foreign!;
final token = await showTokenPickerSheet(
context,
- current: _fromToken,
- loadTokens: ({bool forceRefresh = false}) => _swapService.getFromTokens(limit: 10, forceRefresh: forceRefresh),
+ current: foreign,
+ loadTokens: ({bool forceRefresh = false}) => service.getFromTokens(limit: 10, forceRefresh: forceRefresh),
);
- if (!mounted) return;
- if (token != null && token != _fromToken) {
- setState(() => _fromToken = token);
- _recalculate();
- }
+ if (token == null || !mounted) return;
+ setState(() => _foreign = token);
+ if (tapped.isQuantus) _setDirection(swapOut: !_swapOut);
}
- void _scanQr() async {
- final address = await Navigator.push(context, MaterialPageRoute(builder: (_) => const QrScannerPage()));
- if (address != null && mounted) {
- _addressController.text = address;
- setState(() {});
- }
+ Future _addAddress(SwapToken from, SwapToken to, SwapToken foreign) async {
+ final service = ref.read(swapServiceProvider);
+ final account = widget.account;
+ final amount = _amountIn(from);
+ final swapOut = _swapOut;
+ final quote = await showSwapAddressSheet(
+ context,
+ token: foreign,
+ role: swapOut ? SwapAddressRole.recipient : SwapAddressRole.refund,
+ quote: (address) => service.getQuote(
+ from: from,
+ to: to,
+ amount: amount,
+ refundAddress: swapOut ? account.accountId : address,
+ recipient: swapOut ? address : account.accountId,
+ slippageBps: ref.read(swapSlippageBpsProvider),
+ ),
+ );
+ if (quote == null || !mounted) return;
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => ReviewSwapScreen(account: account, quote: quote),
+ ),
+ );
}
@override
@@ -123,6 +125,8 @@ class _SwapScreenState extends ConsumerState {
final l10n = ref.watch(l10nProvider);
final colors = context.colorsV3;
final text = context.themeTextV3;
+ final foreign = _foreign;
+ final SwapToken quantus = _listedQuantus ?? ref.watch(quantusSwapTokenProvider);
return ScaffoldBase(
appBar: V2AppBar(
@@ -130,288 +134,277 @@ class _SwapScreenState extends ConsumerState {
slotWidth: NearIntentsAttribution.width,
trailing: const NearIntentsAttribution(),
),
- mainContent: Column(
+ mainContent: foreign == null
+ ? _tokensState(l10n, colors, text)
+ : _form(l10n, colors, text, _swapOut ? quantus : foreign, _swapOut ? foreign : quantus),
+ bottomContent: foreign == null
+ ? null
+ : _cta(l10n, _swapOut ? quantus : foreign, _swapOut ? foreign : quantus, foreign),
+ );
+ }
+
+ Widget _tokensState(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
+ if (_loadingTokens) return const Center(child: Loader());
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
children: [
- const SizedBox(height: 24),
- Expanded(
- child: SingleChildScrollView(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _fromSection(l10n, colors, text),
- const SizedBox(height: 32),
- _refundAddressSection(l10n, colors, text),
- const SizedBox(height: 32),
- _swapDivider(colors),
- const SizedBox(height: 32),
- _toSection(l10n, colors, text),
- const SizedBox(height: 32),
- _infoSection(l10n, colors, text),
- ],
- ),
- ),
+ Text(l10n.swapTokenPickerLoadError, style: text.body.copyWith(color: colors.textMuted)),
+ const SizedBox(height: 12),
+ GestureDetector(
+ onTap: () => _loadTokens(forceRefresh: true),
+ child: Text(l10n.commonTryAgain, style: text.body.copyWith(color: colors.accentFlare)),
),
- const SizedBox(height: 16),
- _quoteButton(l10n),
- const SizedBox(height: 24),
],
),
);
}
- Widget _fromSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.swapFrom, style: text.body.copyWith(color: colors.textContent)),
- const SizedBox(height: 12),
- Row(
- children: [
- Expanded(
- child: Container(
- height: 56,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder),
- alignment: Alignment.centerLeft,
- child: TextField(
- controller: _fromController,
- style: text.amountHero.copyWith(color: colors.textContent),
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- decoration: InputDecoration(
- hintText: SwapService.formatTokenAmountHint(_fromToken),
- hintStyle: text.amountHero.copyWith(color: colors.textMuted2),
- border: InputBorder.none,
- isDense: true,
- contentPadding: EdgeInsets.zero,
- filled: true,
- fillColor: Colors.transparent,
+ Widget _cta(AppLocalizations l10n, SwapToken from, SwapToken to, SwapToken foreign) {
+ final amountIn = _amountIn(from);
+ final spendable = _swapOut ? ref.watch(effectiveMaxBalanceProviderFamily(widget.account.accountId)).value : null;
+ final insufficient = spendable != null && amountIn > spendable;
+ return ScaffoldBaseBottomContent(
+ child: QuantusButton.simple(
+ label: insufficient
+ ? l10n.sendLogicInsufficientBalance
+ : _swapOut
+ ? l10n.swapAddRecipientAddress
+ : l10n.swapAddRefundAddress,
+ onTap: () => _addAddress(from, to, foreign),
+ isDisabled: amountIn == BigInt.zero || insufficient,
+ ),
+ );
+ }
+
+ Widget _form(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) {
+ final fmt = ref.watch(numberFormattingServiceProvider);
+ final amountIn = _amountIn(from);
+ final estimateOut = swapEstimateOut(amountIn, from, to);
+ final oneFrom = BigInt.from(10).pow(from.decimals);
+ final priced = from.usdPrice > 0 && to.usdPrice > 0;
+ final wallet = widget.account.name;
+ final external = l10n.swapExternalWallet;
+
+ return SingleChildScrollView(
+ child: Column(
+ children: [
+ const SizedBox(height: 24),
+ Container(
+ decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.pillBorder),
+ clipBehavior: Clip.antiAlias,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Column(
+ children: [
+ _section(
+ colors,
+ text,
+ label: l10n.swapFrom,
+ owner: _swapOut ? wallet : external,
+ token: from,
+ usd: swapUsdValue(amountIn, from),
+ amount: _amountField(colors, text, from),
+ ),
+ Container(height: 4, color: colors.bgVoid),
+ _section(
+ colors,
+ text,
+ label: l10n.swapTo,
+ owner: _swapOut ? external : wallet,
+ token: to,
+ usd: swapUsdValue(estimateOut, to),
+ amount: _amountBox(
+ colors,
+ Text(
+ fmt.formatAmount(estimateOut, decimals: to.decimals),
+ style: text.amountInline.copyWith(
+ color: estimateOut > BigInt.zero ? colors.textContent : colors.textMuted2,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ ],
+ ),
+ GestureDetector(
+ onTap: () => _setDirection(swapOut: !_swapOut),
+ child: Container(
+ width: 48,
+ height: 48,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle),
+ child: SvgPicture.asset(
+ _directionIconAsset,
+ colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn),
+ ),
),
),
- ),
+ ],
),
- const SizedBox(width: 16),
- SizedBox(
- width: 119,
- height: 56,
- child: QuantusButton(
- centered: false,
- variant: ButtonVariant.glass,
- onTap: _pickToken,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
- borderRadius: context.radiusV3.mdBorder,
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ if (priced)
+ Expanded(
+ child: Text(
+ l10n.swapRateLabel(
+ from.symbol,
+ fmt.formatAmount(swapEstimateOut(oneFrom, from, to), decimals: to.decimals),
+ to.symbol,
+ ),
+ style: text.bodyEmphasis.copyWith(color: colors.textMuted2),
+ ),
+ )
+ else
+ const Spacer(),
+ GestureDetector(
+ onTap: () => showSwapSlippageSheet(context),
+ behavior: HitTestBehavior.opaque,
child: Row(
+ mainAxisSize: MainAxisSize.min,
children: [
- TokenIcon(token: _fromToken, size: 25, networkBadgeSize: 10),
+ Text(
+ l10n.swapSlippageLabel(slippagePercentLabel(ref.watch(swapSlippageBpsProvider))),
+ style: text.bodyEmphasis.copyWith(color: colors.textMuted2),
+ ),
const SizedBox(width: 8),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- _fromToken.symbol,
- style: text.caption.copyWith(color: colors.textContent),
- overflow: TextOverflow.ellipsis,
- ),
- Text(
- _fromToken.network,
- style: text.caption.copyWith(color: colors.textMuted),
- overflow: TextOverflow.ellipsis,
- ),
- ],
+ Container(
+ width: 20,
+ height: 20,
+ padding: const EdgeInsets.all(5),
+ decoration: BoxDecoration(
+ color: colors.bgVoid,
+ shape: BoxShape.circle,
+ border: Border.all(color: colors.borderHairline),
+ ),
+ child: SvgPicture.asset(
+ _slippageIconAsset,
+ colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn),
),
),
- const SizedBox(width: 4),
- QuantusIcon(QuantusIcons.caretDown, color: colors.textMuted, size: 16),
],
),
),
- ),
- ],
- ),
- const SizedBox(height: 4),
- Row(
- children: [
- Text('\$${_fromUsd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)),
- const SizedBox(width: 4),
- QuantusIcon(QuantusIcons.swapVertical, color: colors.textMuted, size: 12),
- ],
- ),
- ],
+ ],
+ ),
+ ],
+ ),
);
}
- Widget _refundAddressSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Text(l10n.swapRefundAddress, style: text.body.copyWith(color: colors.textContent)),
- const SizedBox(width: 4),
- Icon(Icons.info_outline, color: colors.textMuted, size: 14),
- ],
- ),
- const SizedBox(height: 12),
- Container(
- decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder),
- padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 8),
- child: Row(
+ Widget _section(
+ AppColorsV3 colors,
+ AppTextThemeV3 text, {
+ required String label,
+ required String owner,
+ required SwapToken token,
+ required double usd,
+ required Widget amount,
+ }) {
+ return Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(label.toUpperCase(), style: text.labelData.copyWith(color: colors.textMuted)),
+ Text(owner, style: text.caption.copyWith(color: colors.textMuted)),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Row(
children: [
Expanded(
- child: TextField(
- controller: _addressController,
- style: text.dataAddressLarge.copyWith(color: colors.textContent),
- decoration: InputDecoration(
- hintText: l10n.swapRefundAddressHint(_fromToken.network),
- hintStyle: text.dataAddressLarge.copyWith(color: colors.textMuted),
- border: InputBorder.none,
- isDense: true,
- contentPadding: EdgeInsets.zero,
- filled: true,
- fillColor: Colors.transparent,
- ),
- onChanged: (_) => setState(() {}),
- ),
- ),
- const SizedBox(width: 8),
- _smallIconButton(colors: colors, iconAsset: _qrIconAsset, onTap: _scanQr),
- const SizedBox(width: 8),
- _smallIconButton(
- colors: colors,
- iconAsset: _historyIconAsset,
- onTap: () async {
- final address = await showRefundAddressPickerSheet(context, _fromToken.network);
- if (address != null) {
- _addressController.text = address;
- setState(() {});
- }
- },
+ child: Align(alignment: Alignment.centerLeft, child: _tokenPill(colors, text, token)),
),
+ const SizedBox(width: 12),
+ amount,
],
),
- ),
- ],
- );
- }
-
- Widget _swapDivider(AppColorsV3 colors) {
- return Row(
- children: [
- const Expanded(child: MenuDivider()),
- SizedBox(
- width: 40,
- height: 40,
- child: _smallIconButton(colors: colors, iconAsset: _swapDirectionIconAsset, onTap: () {}),
- ),
- const Expanded(child: MenuDivider()),
- ],
+ const SizedBox(height: 12),
+ Align(
+ alignment: Alignment.centerRight,
+ child: Text('\$${usd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)),
+ ),
+ ],
+ ),
);
}
- Widget _smallIconButton({required AppColorsV3 colors, required String iconAsset, VoidCallback? onTap}) {
+ Widget _tokenPill(AppColorsV3 colors, AppTextThemeV3 text, SwapToken token) {
return SizedBox(
- width: 40,
- height: 40,
+ height: _rowHeight,
child: QuantusButton(
- onTap: onTap,
+ width: null,
+ centered: false,
variant: ButtonVariant.glass,
- padding: EdgeInsets.zero,
- borderRadius: context.radiusV3.smBorder,
- child: Center(
- child: SvgPicture.asset(
- iconAsset,
- width: 20,
- height: 20,
- colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn),
- ),
- ),
- ),
- );
- }
-
- Widget _toSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.swapTo, style: text.body.copyWith(color: colors.textContent)),
- const SizedBox(height: 12),
- Row(
+ onTap: () => _pickToken(token),
+ padding: const EdgeInsets.only(left: 6, right: 12),
+ borderRadius: context.radiusV3.pillBorder,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
children: [
- Expanded(
- child: Container(
- height: 56,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder),
- alignment: Alignment.centerLeft,
- child: Text(
- SwapService.formatTokenAmount(_toAmount, _swapService.getQuToken()),
- style: text.amountHero.copyWith(color: _toAmount > 0 ? colors.textContent : colors.textMuted2),
- ),
- ),
- ),
- const SizedBox(width: 16),
- SizedBox(
- width: 119,
- height: 56,
- child: QuantusButton(
- centered: false,
- variant: ButtonVariant.glass,
- onTap: () {},
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
- borderRadius: context.radiusV3.mdBorder,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- TokenIcon(token: _swapService.getQuToken(), size: 25, networkBadgeSize: 10),
- const SizedBox(width: 8),
- Text(AppConstants.tokenSymbol, style: text.body.copyWith(color: colors.textContent)),
- ],
- ),
+ TokenIcon(token: token, size: 32, networkBadgeSize: 12),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ token.symbol,
+ style: text.bodyEmphasis.copyWith(color: colors.textContent),
+ overflow: TextOverflow.ellipsis,
+ ),
+ Text(
+ token.networkName,
+ style: text.caption.copyWith(color: colors.textMuted),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
),
),
+ const SizedBox(width: 8),
+ QuantusIcon(QuantusIcons.caretDown, color: colors.textMuted, size: 14),
],
),
- const SizedBox(height: 4),
- Text('\$${_toUsd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)),
- ],
+ ),
);
}
- Widget _infoSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) {
- return Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(l10n.swapSlippageTolerance, style: text.caption.copyWith(color: colors.textMuted)),
- Row(
- children: [
- Text('1%', style: text.caption.copyWith(color: colors.textMuted)),
- const SizedBox(width: 4),
- Icon(Icons.settings, color: colors.textMuted, size: 12),
- ],
- ),
- ],
- ),
- const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(l10n.swapRate, style: text.caption.copyWith(color: colors.textMuted)),
- Text(_rateLabel(l10n), style: text.caption.copyWith(color: colors.textMuted)),
- ],
- ),
- ],
+ Widget _amountBox(AppColorsV3 colors, Widget child) {
+ return Container(
+ width: _amountBoxWidth,
+ height: _rowHeight,
+ padding: const EdgeInsets.only(left: 12, right: 8),
+ alignment: Alignment.centerRight,
+ decoration: BoxDecoration(color: colors.textWhite.useOpacity(0.05), borderRadius: context.radiusV3.smBorder),
+ child: child,
);
}
- Widget _quoteButton(AppLocalizations l10n) {
- final enabled = _canGetQuote && !_loading;
- return QuantusButton.simple(
- label: l10n.swapGetQuote,
- onTap: _getQuote,
- isDisabled: !enabled,
- variant: ButtonVariant.staged,
+ Widget _amountField(AppColorsV3 colors, AppTextThemeV3 text, SwapToken from) {
+ return _amountBox(
+ colors,
+ TextField(
+ controller: _amountController,
+ textAlign: TextAlign.right,
+ style: text.amountInline.copyWith(color: colors.textContent),
+ cursorColor: colors.accentFlare,
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [
+ DecimalInputFilter(localeConfig: ref.watch(localeNumberConfigProvider), maxDecimalPlaces: from.decimals),
+ ],
+ decoration: InputDecoration.collapsed(
+ hintText: '0',
+ hintStyle: text.amountInline.copyWith(color: colors.textMuted2),
+ ),
+ ),
);
}
}
diff --git a/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart b/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart
new file mode 100644
index 000000000..534e79f50
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:quantus_sdk/quantus_sdk.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+
+/// Picks the slippage tolerance for the next quote; closes on a pick.
+Future showSwapSlippageSheet(BuildContext context) =>
+ BottomSheetContainer.show(context, builder: (_) => const _SwapSlippageSheet());
+
+class _SwapSlippageSheet extends ConsumerWidget {
+ const _SwapSlippageSheet();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = ref.watch(l10nProvider);
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+
+ return BottomSheetContainer(
+ title: l10n.swapSlippageTitle,
+ trailing: QuantusIconButton.ghost(
+ icon: Icons.close,
+ onTap: () => Navigator.pop(context),
+ size: IconButtonSize.small,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(l10n.swapSlippageBody, style: text.body.copyWith(color: colors.textMuted)),
+ const SizedBox(height: 24),
+ SegmentedControls(
+ items: [
+ for (final bps in swapSlippageOptionsBps)
+ SegmentedControlItem(value: bps, label: l10n.swapSlippagePercent(slippagePercentLabel(bps))),
+ ],
+ selectedValue: ref.watch(swapSlippageBpsProvider),
+ onChanged: (bps) {
+ ref.read(swapSlippageBpsProvider.notifier).state = bps;
+ Navigator.pop(context);
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile-app/lib/v2/screens/swap/swap_summary.dart b/mobile-app/lib/v2/screens/swap/swap_summary.dart
new file mode 100644
index 000000000..c8046bdd8
--- /dev/null
+++ b/mobile-app/lib/v2/screens/swap/swap_summary.dart
@@ -0,0 +1,133 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:quantus_sdk/quantus_sdk.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/providers/wallet_providers.dart';
+import 'package:resonance_network_wallet/v2/components/token_icon.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+
+/// Pay/receive summary of a swap, shared by the review, progress and failed screens.
+class SwapAmountsCard extends ConsumerWidget {
+ static const _arrowAsset = 'assets/v2/swap_arrow_down.svg';
+
+ final SwapQuote quote;
+ final String payLabel;
+ final String receiveLabel;
+ final BigInt? amountOut;
+ final bool receiveDimmed;
+
+ const SwapAmountsCard({
+ super.key,
+ required this.quote,
+ required this.payLabel,
+ required this.receiveLabel,
+ this.amountOut,
+ this.receiveDimmed = false,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final colors = context.colorsV3;
+ return Container(
+ decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.pillBorder),
+ clipBehavior: Clip.antiAlias,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Column(
+ children: [
+ _row(context, ref, quote.fromToken, quote.amountIn, payLabel, dimmed: false, exact: true),
+ Container(height: 4, color: colors.bgVoid),
+ _row(context, ref, quote.toToken, amountOut ?? quote.amountOut, receiveLabel, dimmed: receiveDimmed),
+ ],
+ ),
+ Container(
+ width: 32,
+ height: 32,
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle),
+ child: SvgPicture.asset(_arrowAsset, colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _row(
+ BuildContext context,
+ WidgetRef ref,
+ SwapToken token,
+ BigInt amount,
+ String label, {
+ required bool dimmed,
+ bool exact = false,
+ }) {
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ return Padding(
+ padding: const EdgeInsets.all(24),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ TokenIcon(token: token, size: 32, networkBadgeSize: 12),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label.toUpperCase(), style: text.labelMonogram.copyWith(color: colors.textMuted)),
+ const SizedBox(height: 8),
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: Alignment.centerLeft,
+ child: Text(
+ formatSwapAmount(
+ ref.watch(l10nProvider),
+ ref.watch(numberFormattingServiceProvider),
+ amount,
+ token,
+ exact: exact,
+ ),
+ style: text.amountHero.copyWith(color: dimmed ? colors.textMuted2 : colors.textContent),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Label/value line of a swap summary; [monoLabel] gives the uppercase mono
+/// label of the review screen.
+class SwapDetailRow extends StatelessWidget {
+ final String label;
+ final String value;
+ final bool monoLabel;
+ final bool monoValue;
+
+ const SwapDetailRow({
+ super.key,
+ required this.label,
+ required this.value,
+ this.monoLabel = false,
+ this.monoValue = false,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.colorsV3;
+ final text = context.themeTextV3;
+ return DetailSummaryRow(
+ label: monoLabel ? label.toUpperCase() : label,
+ value: value,
+ labelFlex: 1,
+ valueFlex: 1,
+ labelStyle: (monoLabel ? text.dataAddress : text.caption).copyWith(color: colors.textMuted),
+ valueStyle: (monoValue ? text.dataAddress : text.labelChip).copyWith(color: colors.textContent),
+ );
+ }
+}
diff --git a/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart b/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart
index e2c2628ec..4c0acb77f 100644
--- a/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart
+++ b/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart
@@ -34,8 +34,8 @@ class _MainnetMigrationScreenState extends ConsumerState
final _pages = PageController();
final _checkingShown = Future.delayed(_checkingMinimum);
- /// Debug builds hold the checking page until an outcome is picked.
- late bool _armed = !ref.read(debugMainnetMigrationProvider);
+ /// Turns to the outcome page once, when the check first settles.
+ bool _armed = true;
bool _creating = false;
@override
@@ -52,12 +52,6 @@ class _MainnetMigrationScreenState extends ConsumerState
if (mounted) await _turnTo(1);
}
- void _pickOutcome(String? outcome) {
- ref.read(forcedTestnetOutcomeProvider.notifier).state = outcome;
- ref.invalidate(testnetStatusProvider);
- _armed = true;
- }
-
Future _keep(TestnetUserKind? kind) async {
try {
await ref.read(mainnetMigrationServiceProvider).markDone();
@@ -114,7 +108,7 @@ class _MainnetMigrationScreenState extends ConsumerState
controller: _pages,
physics: const NeverScrollableScrollPhysics(),
children: [
- _CheckingPage(l10n: l10n, onPickOutcome: ref.watch(debugMainnetMigrationProvider) ? _pickOutcome : null),
+ _CheckingPage(l10n: l10n),
_OutcomePage(
l10n: l10n,
status: status,
@@ -130,9 +124,8 @@ class _MainnetMigrationScreenState extends ConsumerState
class _CheckingPage extends StatelessWidget {
final AppLocalizations l10n;
- final ValueChanged? onPickOutcome;
- const _CheckingPage({required this.l10n, required this.onPickOutcome});
+ const _CheckingPage({required this.l10n});
@override
Widget build(BuildContext context) {
@@ -156,27 +149,6 @@ class _CheckingPage extends StatelessWidget {
),
const SizedBox(height: 16),
Text(l10n.mainnetMigrationChecking, style: text.bodyLarge.copyWith(color: colors.textMuted)),
- if (onPickOutcome != null) ...[
- const SizedBox(height: 24),
- Text('DEBUG: pick the testnet outcome', style: text.caption.copyWith(color: colors.textMuted)),
- const SizedBox(height: 8),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- for (final outcome in [...debugTestnetOutcomes, null])
- IntrinsicWidth(
- child: QuantusButton.simple(
- label: outcome ?? 'real',
- onTap: () => onPickOutcome!(outcome),
- variant: ButtonVariant.staged,
- width: null,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- ),
- ),
- ],
- ),
- ],
const SizedBox(height: 52),
LinearProgressIndicator(
minHeight: 4,
diff --git a/mobile-app/pubspec.lock b/mobile-app/pubspec.lock
index 958342931..e6c301ee5 100644
--- a/mobile-app/pubspec.lock
+++ b/mobile-app/pubspec.lock
@@ -346,7 +346,7 @@ packages:
source: hosted
version: "2.1.0"
ed25519_edwards:
- dependency: transitive
+ dependency: "direct dev"
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml
index 9abdd4329..de238bfd0 100644
--- a/mobile-app/pubspec.yaml
+++ b/mobile-app/pubspec.yaml
@@ -57,6 +57,7 @@ dependencies:
async: ^2.13.1
dev_dependencies:
+ ed25519_edwards: ^0.3.1
flutter_test:
sdk: flutter
integration_test:
@@ -95,8 +96,12 @@ flutter:
- assets/v2/uppercase_q.png
- assets/v2/hand_arrow_down.svg
- assets/v2/swap_arrows_down_up.svg
- - assets/v2/swap_clock_counter_clockwise.svg
- assets/v2/swap_qr_code.svg
+ - assets/v2/swap_arrow_down.svg
+ - assets/v2/swap_list.svg
+ - assets/v2/swap_address_book.svg
+ - assets/v2/swap_pencil.svg
+ - assets/v2/token_qtc.svg
- assets/v2/auth_wrapper_bracket.png
- assets/v2/splash_background.jpg
- assets/v2/ascii_background.png
diff --git a/mobile-app/test/screens/mainnet_migration_screen_test.dart b/mobile-app/test/screens/mainnet_migration_screen_test.dart
index bbc4d259d..a98d8e6f6 100644
--- a/mobile-app/test/screens/mainnet_migration_screen_test.dart
+++ b/mobile-app/test/screens/mainnet_migration_screen_test.dart
@@ -27,12 +27,6 @@ class _Settings extends FakeSettingsService {
}
}
-/// Never answers, so only a picked outcome can move the debug flow along.
-class _SilentService extends Fake implements MainnetMigrationService {
- @override
- Future checkTestnetStatus() => Completer().future;
-}
-
final _miner = TestnetStatus(blocksMined: 1234, balance: BigInt.zero);
final _holder = TestnetStatus(blocksMined: 0, balance: BigInt.from(7));
final _newcomer = TestnetStatus(blocksMined: 0, balance: BigInt.zero);
@@ -53,7 +47,6 @@ void main() {
WidgetTester tester,
Future Function() status, {
bool failWrite = false,
- bool debugPicker = false,
}) async {
final settings = _Settings()..failWrite = failWrite;
final finished = [];
@@ -61,11 +54,7 @@ void main() {
MainnetMigrationScreen(onFinished: () => finished.add(true)),
overrides: [
settingsServiceProvider.overrideWithValue(settings),
- debugMainnetMigrationProvider.overrideWithValue(debugPicker),
- if (debugPicker)
- mainnetMigrationServiceProvider.overrideWithValue(_SilentService())
- else
- testnetStatusProvider.overrideWith((ref) => status()),
+ testnetStatusProvider.overrideWith((ref) => status()),
],
);
await tester.pump();
@@ -186,18 +175,4 @@ void main() {
expect(find.text('READING TESTNET HISTORY'), findsOneWidget);
expect(keep, findsNothing);
});
-
- testWidgets('debug builds hold the checking page until an outcome is picked', (tester) async {
- await pumpFlow(tester, () async => _miner, debugPicker: true);
- await tester.pump(const Duration(seconds: 3));
- expect(keep, findsNothing);
- for (final outcome in debugTestnetOutcomes) {
- expect(find.text(outcome), findsOneWidget);
- }
-
- await tester.tap(find.text('error'));
- await tester.pump();
- await turnPage(tester);
- expect(find.text("We couldn't check this wallet."), findsOneWidget);
- });
}
diff --git a/mobile-app/test/screens/swap_flow_test.dart b/mobile-app/test/screens/swap_flow_test.dart
new file mode 100644
index 000000000..b8055c565
--- /dev/null
+++ b/mobile-app/test/screens/swap_flow_test.dart
@@ -0,0 +1,396 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:ed25519_edwards/ed25519_edwards.dart' as ed25519;
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_riverpod/misc.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:quantus_sdk/quantus_sdk.dart';
+import 'package:resonance_network_wallet/l10n/app_localizations.dart';
+import 'package:resonance_network_wallet/providers/connectivity_provider.dart';
+import 'package:resonance_network_wallet/providers/l10n_provider.dart';
+import 'package:resonance_network_wallet/providers/wallet_providers.dart';
+import 'package:resonance_network_wallet/services/transaction_submission_service.dart';
+import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/review_swap_screen.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_progress_screen.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart';
+import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../extensions.dart';
+import '../fakes.dart';
+
+const _usdc = SwapToken(assetId: 'nep141:usdc.omft.near', symbol: 'USDC', network: 'ETH', decimals: 6, usdPrice: 1);
+const _external = '0xa5f3c2b1d4e8a7c9f2b5e6d7c8b9a0f1e2d3c7b9';
+final _deposit = 'qzdeposit${'y' * 40}';
+final _qtc = SwapService.quantusToken(usdPrice: 0.1);
+final _managerKeyPair = ed25519.generateKey();
+final _managerPublicKey = OneClickQuoteSignature.encodeKey(Uint8List.fromList(_managerKeyPair.publicKey.bytes));
+final _unit = BigInt.from(10).pow(AppConstants.decimals);
+
+class _FakeSubmission extends Fake implements TransactionSubmissionService {
+ final transfers = <(String, BigInt, BigInt)>[];
+
+ @override
+ Future balanceTransfer(
+ Account account, {
+ required RuntimeCall call,
+ required String targetAddress,
+ required BigInt amount,
+ required BigInt fee,
+ }) async {
+ transfers.add((targetAddress, amount, fee));
+ return '0xtxhash';
+ }
+}
+
+/// 1Click stand-in: a dry quote pays [dryOut], a live one [liveOut] with a
+/// deposit address, both with 1% slippage.
+class _OneClick {
+ final BigInt dryOut;
+ final BigInt liveOut;
+ final requests = [];
+
+ _OneClick({required this.dryOut, BigInt? liveOut}) : liveOut = liveOut ?? dryOut;
+
+ Iterable get liveQuotes =>
+ requests.where((r) => r.url.path == '/v0/quote' && (jsonDecode(r.body) as Map)['dry'] == false);
+
+ SwapService service() =>
+ SwapService(endpoint: 'https://oneclick.test', client: MockClient(_handle), managerPublicKey: _managerPublicKey);
+
+ Future _handle(http.Request request) async {
+ requests.add(request);
+ return switch (request.url.path) {
+ '/v0/tokens' => http.Response(
+ jsonEncode([
+ {'assetId': _usdc.assetId, 'decimals': 6, 'blockchain': 'eth', 'symbol': 'USDC', 'price': 1},
+ ]),
+ 200,
+ ),
+ '/v0/quote' => http.Response(jsonEncode(_quoteJson(jsonDecode(request.body) as Map)), 200),
+ '/v0/deposit/submit' => http.Response('{}', 200),
+ '/v0/status' => http.Response(jsonEncode({'status': 'PENDING_DEPOSIT'}), 200),
+ _ => http.Response('down', 503),
+ };
+ }
+
+ Map _quoteJson(Map request) {
+ final dry = request['dry'] as bool;
+ final out = dry ? dryOut : liveOut;
+ final response = {
+ 'quote': {
+ if (!dry) 'depositAddress': _deposit,
+ 'amountIn': request['amount'],
+ 'amountOut': '$out',
+ 'minAmountOut': '${out * BigInt.from(99) ~/ BigInt.from(100)}',
+ 'amountInUsd': '25.0',
+ 'amountOutUsd': '24.86',
+ if (!dry) 'deadline': DateTime.now().toUtc().add(const Duration(minutes: 20)).toIso8601String(),
+ 'timeEstimate': 60,
+ },
+ 'quoteRequest': {
+ for (final k in const [
+ 'dry',
+ 'originAsset',
+ 'destinationAsset',
+ 'amount',
+ 'slippageTolerance',
+ 'refundTo',
+ 'recipient',
+ 'deadline',
+ ])
+ k: request[k],
+ },
+ 'correlationId': dry ? 'dry' : 'live',
+ 'timestamp': DateTime.now().toUtc().toIso8601String(),
+ };
+ final message = utf8.encode(OneClickQuoteSignature.hash(response));
+ return {
+ ...response,
+ 'signature': OneClickQuoteSignature.encodeKey(ed25519.sign(_managerKeyPair.privateKey, message)),
+ };
+ }
+}
+
+SwapQuote _quote({
+ required SwapToken from,
+ required SwapToken to,
+ required BigInt amountIn,
+ required BigInt amountOut,
+ required String refund,
+ required String recipient,
+ String? depositAddress,
+ String? depositMemo,
+}) => SwapQuote(
+ fromToken: from,
+ toToken: to,
+ amountIn: amountIn,
+ amountOut: amountOut,
+ minAmountOut: amountOut * BigInt.from(99) ~/ BigInt.from(100),
+ amountInUsd: 25,
+ amountOutUsd: 24.86,
+ slippageBps: 100,
+ refundAddress: refund,
+ recipient: recipient,
+ deadline: DateTime.now().add(const Duration(minutes: 20)),
+ timeEstimate: const Duration(minutes: 1),
+ correlationId: 'c',
+ signature: 'ed25519:sig',
+ depositAddress: depositAddress,
+ depositMemo: depositMemo,
+);
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ final l10n = lookupAppLocalizations(const Locale('en'));
+ final account = makeAccount(1);
+ final outQuote = _quote(
+ from: _qtc,
+ to: _usdc,
+ amountIn: _unit * BigInt.from(250),
+ amountOut: BigInt.from(24860000),
+ refund: account.accountId,
+ recipient: _external,
+ );
+
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ await SettingsService().initialize();
+ });
+
+ List overrides(SwapService service, {BigInt? balance, _FakeSubmission? submission}) => [
+ settingsServiceProvider.overrideWithValue(FakeSettingsService(activeAccount: RegularAccount(account))),
+ isOnlineProvider.overrideWith((ref) => true),
+ l10nProvider.overrideWithValue(l10n),
+ swapServiceProvider.overrideWithValue(service),
+ quantusSwapTokenProvider.overrideWithValue(_qtc),
+ effectiveMaxBalanceProviderFamily.overrideWith((ref, _) => AsyncValue.data(balance ?? _unit * BigInt.from(1000))),
+ swapDepositFeeProvider.overrideWith((ref, _) async => _unit ~/ BigInt.from(50)),
+ balancesServiceProvider.overrideWithValue(FakeBalancesService()),
+ transactionSubmissionServiceProvider.overrideWithValue(submission ?? _FakeSubmission()),
+ swapOrderProvider.overrideWith((ref, order) => Stream.value(order)),
+ ];
+
+ Future settle(WidgetTester tester) async {
+ for (var i = 0; i < 5; i++) {
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+ }
+
+ Finder svg(String asset) =>
+ find.byWidgetPredicate((w) => w is SvgPicture && (w.bytesLoader as SvgAssetLoader).assetName == asset);
+
+ QuantusButton button(WidgetTester tester, String label) =>
+ tester.widget(find.ancestor(of: find.text(label), matching: find.byType(QuantusButton)));
+
+ group('SwapScreen', () {
+ testWidgets('swaps out of QTC by default and flips direction with the arrows', (tester) async {
+ final oneClick = _OneClick(dryOut: BigInt.from(24860000));
+ await tester.pumpApp(
+ SwapScreen(account: account),
+ overrides: overrides(oneClick.service(), balance: _unit * BigInt.from(100)),
+ );
+ await settle(tester);
+
+ expect(find.text('FROM'), findsOneWidget);
+ expect(find.text(account.name), findsOneWidget);
+ expect(find.text(l10n.swapExternalWallet), findsOneWidget);
+ expect(button(tester, l10n.swapAddRecipientAddress).isDisabled, isTrue);
+
+ await tester.enterText(find.byType(TextField), '1000');
+ await tester.pump();
+ expect(button(tester, l10n.sendLogicInsufficientBalance).isDisabled, isTrue);
+
+ await tester.enterText(find.byType(TextField), '10');
+ await tester.pump();
+ expect(button(tester, l10n.swapAddRecipientAddress).isDisabled, isFalse);
+ expect(find.text('1'), findsOneWidget);
+
+ final arrows = svg('assets/v2/swap_arrows_down_up.svg');
+ await tester.tap(arrows);
+ await tester.pump();
+
+ expect(button(tester, l10n.swapAddRefundAddress).isDisabled, isTrue);
+ final fromHeader = tester.getTopLeft(find.text('FROM')).dy;
+ expect(tester.getTopLeft(find.text(l10n.swapExternalWallet)).dy, closeTo(fromHeader, 4));
+ });
+ });
+
+ group('slippage', () {
+ testWidgets('the picked tolerance shows on the form and goes into the quote', (tester) async {
+ final oneClick = _OneClick(dryOut: BigInt.from(24860000));
+ await tester.pumpApp(SwapScreen(account: account), overrides: overrides(oneClick.service()));
+ await settle(tester);
+ expect(find.text(l10n.swapSlippageLabel('1')), findsOneWidget);
+
+ await tester.tap(svg('assets/v2/swap_pencil.svg'));
+ await settle(tester);
+ await tester.tap(find.text('2%'));
+ await settle(tester);
+ expect(find.text(l10n.swapSlippageLabel('2')), findsOneWidget);
+
+ await tester.enterText(find.byType(TextField), '10');
+ await tester.pump();
+ await tester.tap(find.text(l10n.swapAddRecipientAddress));
+ await settle(tester);
+ await tester.enterText(find.byType(TextField).last, _external);
+ await tester.pump();
+ await tester.tap(find.text(l10n.swapContinue));
+ await settle(tester);
+
+ final quote = oneClick.requests.singleWhere((r) => r.url.path == '/v0/quote');
+ expect((jsonDecode(quote.body) as Map)['slippageTolerance'], 200);
+ expect(find.text(l10n.swapReviewTitle), findsOneWidget);
+ });
+ });
+
+ group('ReviewSwapScreen', () {
+ testWidgets('a swap out sends the QTC to the live deposit address and follows the swap', (tester) async {
+ final oneClick = _OneClick(dryOut: outQuote.amountOut);
+ final submission = _FakeSubmission();
+ await tester.pumpApp(
+ ReviewSwapScreen(account: account, quote: outQuote),
+ overrides: overrides(oneClick.service(), submission: submission),
+ );
+ await settle(tester);
+
+ expect(find.text('RECIPIENT'), findsOneWidget);
+ expect(find.text('NETWORK FEE'), findsOneWidget);
+ expect(find.text('0.02 QTC'), findsOneWidget);
+ expect(find.text('24.6114 USDC'), findsOneWidget);
+
+ await tester.tap(find.text(l10n.swapReviewConfirm));
+ await settle(tester);
+
+ expect(submission.transfers, [(_deposit, outQuote.amountIn, _unit ~/ BigInt.from(50))]);
+ final submit = oneClick.requests.singleWhere((r) => r.url.path == '/v0/deposit/submit');
+ expect(jsonDecode(submit.body), {'txHash': '0xtxhash', 'depositAddress': _deposit});
+ expect(find.text(l10n.swapInProgressTitle), findsOneWidget);
+ expect(find.text(l10n.swapStepSent('QTC')), findsOneWidget);
+ });
+
+ testWidgets('a worse live quote replaces the terms and needs a second confirm', (tester) async {
+ final oneClick = _OneClick(dryOut: outQuote.amountOut, liveOut: BigInt.from(20000000));
+ final submission = _FakeSubmission();
+ await tester.pumpApp(
+ ReviewSwapScreen(account: account, quote: outQuote),
+ overrides: overrides(oneClick.service(), submission: submission),
+ );
+ await settle(tester);
+
+ await tester.tap(find.text(l10n.swapReviewConfirm));
+ await settle(tester);
+
+ expect(submission.transfers, isEmpty);
+ expect(find.text(l10n.swapReviewTitle), findsOneWidget);
+ expect(find.text('19.8 USDC'), findsOneWidget);
+
+ await tester.tap(find.text(l10n.swapReviewConfirm));
+ await settle(tester);
+
+ expect(submission.transfers.single.$1, _deposit);
+ expect(oneClick.liveQuotes, hasLength(1));
+ await tester.pump(const Duration(seconds: 3));
+ });
+
+ testWidgets('shows every digit of the amount that will be sent', (tester) async {
+ final oneClick = _OneClick(dryOut: outQuote.amountOut);
+ final quote = _quote(
+ from: _qtc,
+ to: _usdc,
+ amountIn: _unit + BigInt.from(90000000),
+ amountOut: outQuote.amountOut,
+ refund: account.accountId,
+ recipient: _external,
+ );
+ await tester.pumpApp(
+ ReviewSwapScreen(account: account, quote: quote),
+ overrides: overrides(oneClick.service()),
+ );
+ await settle(tester);
+
+ expect(find.text('1.00009 QTC'), findsOneWidget);
+ });
+
+ testWidgets('blocks a swap out the balance cannot cover with its fee', (tester) async {
+ final oneClick = _OneClick(dryOut: outQuote.amountOut);
+ await tester.pumpApp(
+ ReviewSwapScreen(account: account, quote: outQuote),
+ overrides: overrides(oneClick.service(), balance: outQuote.amountIn),
+ );
+ await settle(tester);
+
+ expect(find.text(l10n.swapReviewInsufficient('QTC')), findsOneWidget);
+ expect(button(tester, l10n.swapReviewConfirm).isDisabled, isTrue);
+ });
+ });
+
+ group('SwapProgressScreen', () {
+ Future pumpOrder(WidgetTester tester, SwapOrder order) async {
+ await tester.pumpApp(
+ SwapProgressScreen(account: account, order: order),
+ overrides: overrides(_OneClick(dryOut: BigInt.one).service()),
+ );
+ await settle(tester);
+ }
+
+ testWidgets('shows what arrived where once the swap succeeds', (tester) async {
+ await pumpOrder(tester, SwapOrder(quote: outQuote, status: SwapStatus.success, amountOut: BigInt.from(24900000)));
+
+ expect(find.text(l10n.swapCompleteTitle), findsOneWidget);
+ expect(find.text('24.9 USDC'), findsOneWidget);
+ expect(find.text('Ethereum'), findsOneWidget);
+ });
+
+ testWidgets('tells a refunded swap out that the QTC went back to the account', (tester) async {
+ await pumpOrder(tester, SwapOrder(quote: outQuote, status: SwapStatus.refunded));
+
+ expect(find.text(l10n.swapFailedTitle), findsOneWidget);
+ expect(find.text('REFUNDED'), findsOneWidget);
+ expect(find.text(l10n.swapRefundedBody('250 QTC', account.name, 'USDC')), findsOneWidget);
+ expect(find.text(l10n.swapStartNew), findsOneWidget);
+ });
+
+ testWidgets('shows the deposit address and exact amount while a swap in waits', (tester) async {
+ final inQuote = _quote(
+ from: const SwapToken(assetId: 'nep141:btc', symbol: 'BTC', network: 'BTC', decimals: 8, usdPrice: 86000),
+ to: _qtc,
+ amountIn: BigInt.from(12345678),
+ amountOut: _unit * BigInt.from(1000),
+ refund: 'bc1qrefund',
+ recipient: account.accountId,
+ depositAddress: 'bc1qdeposit',
+ );
+ await pumpOrder(tester, SwapOrder(quote: inQuote, status: SwapStatus.pendingDeposit));
+
+ expect(find.text('bc1qdeposit'), findsOneWidget);
+ expect(find.text('0.12345678'), findsOneWidget);
+ expect(find.text(l10n.swapDepositMemoNotice), findsNothing);
+ });
+
+ testWidgets('shows the memo a deposit must carry', (tester) async {
+ final inQuote = _quote(
+ from: const SwapToken(assetId: 'nep245:xlm', symbol: 'XLM', network: 'STELLAR', decimals: 7, usdPrice: 0.2),
+ to: _qtc,
+ amountIn: BigInt.from(50000000),
+ amountOut: _unit * BigInt.from(10),
+ refund: 'GREFUND',
+ recipient: account.accountId,
+ depositAddress: 'GDEPOSIT',
+ depositMemo: '4183920',
+ );
+ await pumpOrder(tester, SwapOrder(quote: inQuote, status: SwapStatus.pendingDeposit));
+
+ expect(find.text('MEMO'), findsOneWidget);
+ expect(find.text('4183920'), findsOneWidget);
+ expect(find.text(l10n.swapDepositMemoNotice), findsOneWidget);
+ });
+ });
+}
diff --git a/mobile-app/test/unit/locale_number_handling_test.dart b/mobile-app/test/unit/locale_number_handling_test.dart
index f583494ef..a3a51bc99 100644
--- a/mobile-app/test/unit/locale_number_handling_test.dart
+++ b/mobile-app/test/unit/locale_number_handling_test.dart
@@ -539,6 +539,17 @@ void main() {
expect(usService.parseAmount('abc'), isNull);
});
+ test('a token with more decimals than the chain parses at full precision', () {
+ final expected = BigInt.parse('1123456789012345678901234');
+ expect(usService.parseAmount('1.123456789012345678901234', decimals: 24), expected);
+ expect(idService.parseAmount('1,123456789012345678901234', decimals: 24), expected);
+ });
+
+ test('formatExactAmount keeps every significant digit', () {
+ expect(usService.formatExactAmount(BigInt.from(12345678), decimals: 8), '0.12345678');
+ expect(idService.formatExactAmount(BigInt.from(1234567890), decimals: 8), '12,3456789');
+ });
+
test('Indonesian locale: integer without separators', () {
expect(idService.parseAmount('1500'), scaleFactor * BigInt.from(1500));
});
diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart
index fcb816b81..2765e314c 100644
--- a/quantus_sdk/lib/quantus_sdk.dart
+++ b/quantus_sdk/lib/quantus_sdk.dart
@@ -78,6 +78,10 @@ export 'src/services/settings_service.dart';
export 'src/services/substrate_service.dart';
export 'src/services/transaction_fee.dart';
export 'src/services/swap_service.dart';
+export 'src/services/one_click_quote_signature.dart';
+export 'src/models/swap_token.dart';
+export 'src/models/swap_quote.dart';
+export 'src/models/swap_order.dart';
export 'src/services/quersi_service.dart';
export 'src/services/senoti_service.dart';
export 'src/services/circuit_manager.dart';
diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart
index 008b27e4d..c8b238b5c 100644
--- a/quantus_sdk/lib/src/constants/app_constants.dart
+++ b/quantus_sdk/lib/src/constants/app_constants.dart
@@ -36,6 +36,19 @@ class AppConstants {
static const String explorerEndpoint = 'https://explorer.quantus.com';
+ // NEAR Intents 1Click swap API (docs.near-intents.org).
+ static const String oneClickEndpoint = 'https://1click.chaindefuser.com';
+
+ // Distribution channel id 1Click records on every quote.
+ static const String oneClickReferral = 'quantus';
+
+ // Key 1Click signs quotes with; from @defuse-protocol/one-click-sdk-typescript.
+ static const String oneClickManagerPublicKey = 'ed25519:reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc';
+
+ // Quantus is not listed on NEAR Intents yet, so every quote into it is
+ // rejected as "tokenOut is not valid". Replace with the listed asset id.
+ static const String quantusIntentsAssetId = 'nep141:qtc.omft.near';
+
// internal group URL is this (note the /c)
// https://t.me/c/quantusnetwork/2457
// removing the c, we get a better preview page though so we use it without c...
@@ -106,11 +119,6 @@ class AppConstants {
// Always show the home backup nudge regardless of viewed state and balance
static const bool debugAlwaysShowBackupNudge = false;
- // Shows the one-time mainnet migration notice on every debug launch, with an
- // outcome picker on its checking page so every flow can be walked through
- // without real testnet data. Flip to false to see the notice only when due.
- static const bool debugMainnetMigration = true;
-
// Shows an outcome picker on the Airdrop entry screen in debug builds so
// every rewards state can be walked through without a mining wallet.
static const bool debugMiningRewards = true;
diff --git a/quantus_sdk/lib/src/models/swap_order.dart b/quantus_sdk/lib/src/models/swap_order.dart
new file mode 100644
index 000000000..8fdd2e391
--- /dev/null
+++ b/quantus_sdk/lib/src/models/swap_order.dart
@@ -0,0 +1,64 @@
+import 'package:quantus_sdk/src/models/swap_quote.dart';
+
+enum SwapStatus {
+ pendingDeposit('PENDING_DEPOSIT'),
+ knownDepositTx('KNOWN_DEPOSIT_TX'),
+ incompleteDeposit('INCOMPLETE_DEPOSIT'),
+ processing('PROCESSING'),
+ success('SUCCESS'),
+ refunded('REFUNDED'),
+ failed('FAILED');
+
+ const SwapStatus(this.wire);
+ final String wire;
+
+ bool get isFinal => this == success || this == refunded || this == failed;
+
+ static SwapStatus parse(String wire) =>
+ values.firstWhere((s) => s.wire == wire, orElse: () => throw FormatException('Unknown swap status: $wire'));
+}
+
+/// A live quote plus what 1Click has reported about it so far.
+class SwapOrder {
+ final SwapQuote quote;
+ final SwapStatus status;
+ final BigInt? amountOut;
+ final BigInt? refundedAmount;
+ final String? refundReason;
+ final List originTxHashes;
+ final List destinationTxHashes;
+
+ const SwapOrder({
+ required this.quote,
+ required this.status,
+ this.amountOut,
+ this.refundedAmount,
+ this.refundReason,
+ this.originTxHashes = const [],
+ this.destinationTxHashes = const [],
+ });
+
+ String get depositAddress => quote.depositAddress!;
+
+ factory SwapOrder.fromStatusJson(Map json, {required SwapQuote quote}) {
+ final details = json['swapDetails'] as Map?;
+ BigInt? amount(String key) {
+ final raw = details?[key] as String?;
+ return raw == null || raw.isEmpty ? null : BigInt.parse(raw);
+ }
+
+ List hashes(String key) => [
+ for (final tx in details?[key] as List? ?? const []) (tx as Map)['hash'] as String,
+ ];
+
+ return SwapOrder(
+ quote: quote,
+ status: SwapStatus.parse(json['status'] as String),
+ amountOut: amount('amountOut'),
+ refundedAmount: amount('refundedAmount'),
+ refundReason: details?['refundReason'] as String?,
+ originTxHashes: hashes('originChainTxHashes'),
+ destinationTxHashes: hashes('destinationChainTxHashes'),
+ );
+ }
+}
diff --git a/quantus_sdk/lib/src/models/swap_quote.dart b/quantus_sdk/lib/src/models/swap_quote.dart
new file mode 100644
index 000000000..e7da77df5
--- /dev/null
+++ b/quantus_sdk/lib/src/models/swap_quote.dart
@@ -0,0 +1,67 @@
+import 'package:quantus_sdk/src/models/swap_token.dart';
+
+/// A 1Click quote. Amounts are base units of their token. [depositAddress] is
+/// only set on a live (non-dry) quote; funds sent there before [deadline] are
+/// swapped, anything else is refunded to [refundAddress].
+class SwapQuote {
+ final SwapToken fromToken;
+ final SwapToken toToken;
+ final BigInt amountIn;
+ final BigInt amountOut;
+ final BigInt minAmountOut;
+ final double amountInUsd;
+ final double amountOutUsd;
+ final int slippageBps;
+ final String refundAddress;
+ final String recipient;
+ final DateTime deadline;
+ final Duration timeEstimate;
+ final String correlationId;
+
+ /// 1Click's signature over the request and response; what settles a dispute.
+ final String signature;
+ final String? depositAddress;
+ final String? depositMemo;
+
+ const SwapQuote({
+ required this.fromToken,
+ required this.toToken,
+ required this.amountIn,
+ required this.amountOut,
+ required this.minAmountOut,
+ required this.amountInUsd,
+ required this.amountOutUsd,
+ required this.slippageBps,
+ required this.refundAddress,
+ required this.recipient,
+ required this.deadline,
+ required this.timeEstimate,
+ required this.correlationId,
+ required this.signature,
+ this.depositAddress,
+ this.depositMemo,
+ });
+
+ factory SwapQuote.fromJson(Map json, {required SwapToken fromToken, required SwapToken toToken}) {
+ final quote = json['quote'] as Map;
+ final request = json['quoteRequest'] as Map;
+ return SwapQuote(
+ fromToken: fromToken,
+ toToken: toToken,
+ amountIn: BigInt.parse(quote['amountIn'] as String),
+ amountOut: BigInt.parse(quote['amountOut'] as String),
+ minAmountOut: BigInt.parse(quote['minAmountOut'] as String),
+ amountInUsd: double.parse(quote['amountInUsd'] as String),
+ amountOutUsd: double.parse(quote['amountOutUsd'] as String),
+ slippageBps: (request['slippageTolerance'] as num).toInt(),
+ refundAddress: request['refundTo'] as String,
+ recipient: request['recipient'] as String,
+ deadline: DateTime.parse((quote['deadline'] ?? request['deadline']) as String),
+ timeEstimate: Duration(seconds: (quote['timeEstimate'] as num).round()),
+ correlationId: json['correlationId'] as String,
+ signature: json['signature'] as String,
+ depositAddress: quote['depositAddress'] as String?,
+ depositMemo: quote['depositMemo'] as String?,
+ );
+ }
+}
diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart
new file mode 100644
index 000000000..afe17a214
--- /dev/null
+++ b/quantus_sdk/lib/src/models/swap_token.dart
@@ -0,0 +1,66 @@
+import 'package:quantus_sdk/src/constants/app_constants.dart';
+
+class SwapToken {
+ static const _networkNames = {
+ 'ARB': 'Arbitrum',
+ 'AVAX': 'Avalanche',
+ 'BASE': 'Base',
+ 'BCH': 'Bitcoin Cash',
+ 'BERA': 'Berachain',
+ 'BSC': 'BNB Chain',
+ 'BTC': 'Bitcoin',
+ 'CARDANO': 'Cardano',
+ 'DOGE': 'Dogecoin',
+ 'ETH': 'Ethereum',
+ 'GNOSIS': 'Gnosis',
+ 'LTC': 'Litecoin',
+ 'MONAD': 'Monad',
+ 'OP': 'Optimism',
+ 'POL': 'Polygon',
+ 'QUANTUS': 'Quantus',
+ 'SOL': 'Solana',
+ 'STELLAR': 'Stellar',
+ 'SUI': 'Sui',
+ 'TRON': 'Tron',
+ 'ZEC': 'Zcash',
+ };
+
+ final String assetId;
+ final String symbol;
+ final String network;
+ final int decimals;
+ final double usdPrice;
+ final String? iconUrl;
+ final String? networkIconUrl;
+
+ const SwapToken({
+ required this.assetId,
+ required this.symbol,
+ required this.network,
+ required this.decimals,
+ required this.usdPrice,
+ this.iconUrl,
+ this.networkIconUrl,
+ });
+
+ bool get isQuantus => assetId == AppConstants.quantusIntentsAssetId;
+
+ /// Human name of [network], e.g. "Ethereum" for ETH; the code itself when unknown.
+ String get networkName => _networkNames[network] ?? network;
+
+ SwapToken copyWith({String? iconUrl, String? networkIconUrl}) => SwapToken(
+ assetId: assetId,
+ symbol: symbol,
+ network: network,
+ decimals: decimals,
+ usdPrice: usdPrice,
+ iconUrl: iconUrl ?? this.iconUrl,
+ networkIconUrl: networkIconUrl ?? this.networkIconUrl,
+ );
+
+ @override
+ bool operator ==(Object other) => other is SwapToken && assetId == other.assetId;
+
+ @override
+ int get hashCode => assetId.hashCode;
+}
diff --git a/quantus_sdk/lib/src/services/locale_number_config.dart b/quantus_sdk/lib/src/services/locale_number_config.dart
index 446e14d8a..8b8a6a7b5 100644
--- a/quantus_sdk/lib/src/services/locale_number_config.dart
+++ b/quantus_sdk/lib/src/services/locale_number_config.dart
@@ -85,10 +85,12 @@ class LocaleNumberConfig {
/// Hard cap on raw input length, applied before normalization so it must
/// admit locale grouping: the widest legitimate amount is
/// [AppConstants.maxWholeDigits] whole digits, two grouping separators, one
- /// decimal separator, and [AppConstants.decimals] fractional digits
- /// (`20,999,999.999999999999`). External input is attacker-controlled —
- /// reject anything wider before any parsing work happens.
- static const int maxInputLength = AppConstants.maxWholeDigits + 2 + 1 + AppConstants.decimals;
+ /// decimal separator, and [decimals] fractional digits
+ /// (`20,999,999.999999999999` for the chain's own token). External input is
+ /// attacker-controlled — reject anything wider before any parsing work happens.
+ static int maxInputLengthFor(int decimals) => AppConstants.maxWholeDigits + 2 + 1 + decimals;
+
+ static final int maxInputLength = maxInputLengthFor(AppConstants.decimals);
/// The only numeric shape we accept after normalization: digits with an
/// optional single decimal separator, plus the mid-typing `.5` form.
@@ -108,8 +110,8 @@ class LocaleNumberConfig {
/// Throws [InvalidNumberInputException] when the input cannot be parsed.
/// Empty input also throws — callers that want to treat empty as zero should
/// short-circuit before calling.
- Decimal parseDecimal(String input) {
- if (input.length > maxInputLength) {
+ Decimal parseDecimal(String input, {int decimals = AppConstants.decimals}) {
+ if (input.length > maxInputLengthFor(decimals)) {
throw InvalidNumberInputException(rawInput: input, normalized: input);
}
final normalized = normalize(input);
diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart
index ef1d76dba..b929aca8a 100644
--- a/quantus_sdk/lib/src/services/number_formatting_service.dart
+++ b/quantus_sdk/lib/src/services/number_formatting_service.dart
@@ -7,6 +7,10 @@ class NumberFormattingService {
static final BigInt scaleFactorBigInt = BigInt.from(10).pow(decimals);
static final Decimal scaleFactorDecimal = Decimal.fromBigInt(scaleFactorBigInt);
+ static Decimal _scale(int decimals) => decimals == NumberFormattingService.decimals
+ ? scaleFactorDecimal
+ : Decimal.fromBigInt(BigInt.from(10).pow(decimals));
+
final LocaleNumberConfig _localeConfig;
NumberFormattingService({LocaleNumberConfig? localeConfig})
@@ -24,8 +28,13 @@ class NumberFormattingService {
/// Example: 1234500000000 -> "1.2345" (smartDecimals = 4, US locale)
/// Example: 100000000 -> "0.0001" (smartDecimals = 2, extended)
/// Standard token-amount display: 4 smart decimals, extending to the
- /// chain's full precision for smaller amounts.
- String formatAmount(BigInt amount) => formatBalance(amount, smartDecimals: 4, maxDecimals: AppConstants.decimals);
+ /// token's full precision for smaller amounts.
+ String formatAmount(BigInt amount, {int decimals = AppConstants.decimals}) =>
+ formatBalance(amount, decimals: decimals, smartDecimals: 4, maxDecimals: decimals);
+
+ /// Every significant digit of [amount], for an amount someone must send exactly.
+ String formatExactAmount(BigInt amount, {int decimals = AppConstants.decimals}) =>
+ formatBalance(amount, decimals: decimals, smartDecimals: decimals, maxDecimals: decimals);
/// A whole count with the locale's grouping separators, e.g. 5279 -> "5,279".
String formatInteger(int value) => _localeConfig.localize(value.toString());
@@ -38,6 +47,7 @@ class NumberFormattingService {
String formatBalance(
BigInt balance, {
+ int decimals = AppConstants.decimals,
int smartDecimals = 4,
int maxDecimals = 6,
bool addThousandsSeparators = true,
@@ -49,7 +59,7 @@ class NumberFormattingService {
return addSymbol ? '$resultString ${AppConstants.tokenSymbol}' : resultString;
}
- final decimalBalance = (Decimal.fromBigInt(balance) / scaleFactorDecimal).toDecimal(
+ final decimalBalance = (Decimal.fromBigInt(balance) / _scale(decimals)).toDecimal(
scaleOnInfinitePrecision: decimals,
);
@@ -98,10 +108,10 @@ class NumberFormattingService {
/// Formats a balance for payment URL wire transport: dot decimal, no grouping.
///
/// Wire amounts are locale-neutral and must be parsed with [parseWireAmount].
- String formatWireAmount(BigInt balance) {
+ String formatWireAmount(BigInt balance, {int decimals = AppConstants.decimals}) {
return NumberFormattingService(
localeConfig: LocaleNumberConfig.dotDecimal,
- ).formatBalance(balance, smartDecimals: decimals, addThousandsSeparators: false);
+ ).formatBalance(balance, decimals: decimals, smartDecimals: decimals, addThousandsSeparators: false);
}
/// Wire amount shape: NNN[.NNN], dot as the decimal separator, no grouping,
@@ -131,22 +141,22 @@ class NumberFormattingService {
}
/// Parses a user-entered formatted string amount into a raw BigInt amount
- /// scaled by the chain's decimals.
+ /// scaled by [decimals], the chain's by default.
///
/// The input is interpreted using the [LocaleNumberConfig] supplied at
/// construction (decimal/grouping separators come from the user's locale).
/// Returns [BigInt.zero] for an empty string and `null` for unparseable input.
- BigInt? parseAmount(String formattedAmount) {
+ BigInt? parseAmount(String formattedAmount, {int decimals = AppConstants.decimals}) {
if (formattedAmount.isEmpty) {
return BigInt.zero;
}
try {
- final decimalAmount = _localeConfig.parseDecimal(formattedAmount);
+ final decimalAmount = _localeConfig.parseDecimal(formattedAmount, decimals: decimals);
if (decimalAmount.scale > decimals) {
quantusPrint('Warning: Input amount $formattedAmount exceeds $decimals decimals, will be truncated.');
}
- final rawDecimalAmount = decimalAmount * scaleFactorDecimal;
+ final rawDecimalAmount = decimalAmount * _scale(decimals);
return rawDecimalAmount.toBigInt();
} catch (e) {
quantusPrint('Error parsing amount $formattedAmount: $e');
diff --git a/quantus_sdk/lib/src/services/one_click_quote_signature.dart b/quantus_sdk/lib/src/services/one_click_quote_signature.dart
new file mode 100644
index 000000000..1f88ddaa4
--- /dev/null
+++ b/quantus_sdk/lib/src/services/one_click_quote_signature.dart
@@ -0,0 +1,109 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:base_x/base_x.dart';
+import 'package:crypto/crypto.dart';
+import 'package:ed25519_edwards/ed25519_edwards.dart' as ed25519;
+import 'package:quantus_sdk/src/constants/app_constants.dart';
+
+/// 1Click signs every quote response: Ed25519 over the base58 SHA-256 of a
+/// key-sorted JSON of chosen request and quote fields plus the timestamp.
+/// Mirrors verifyQuoteSignature in @defuse-protocol/one-click-sdk-typescript,
+/// so a tampered deposit address or amount fails to verify.
+class OneClickQuoteSignature {
+ static const _prefix = 'ed25519:';
+ static final _base58 = BaseXCodec('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
+ static const _requestFields = [
+ 'dry',
+ 'swapType',
+ 'slippageTolerance',
+ 'originAsset',
+ 'depositType',
+ 'destinationAsset',
+ 'amount',
+ 'refundTo',
+ 'refundType',
+ 'recipient',
+ 'recipientType',
+ 'deadline',
+ ];
+ static const _optionalRequestFields = [
+ 'quoteWaitingTimeMs',
+ 'referral',
+ 'virtualChainRecipient',
+ 'virtualChainRefundRecipient',
+ 'customRecipientMsg',
+ ];
+ static const _quoteFields = [
+ 'amountIn',
+ 'amountInFormatted',
+ 'amountInUsd',
+ 'minAmountIn',
+ 'amountOut',
+ 'amountOutFormatted',
+ 'amountOutUsd',
+ 'minAmountOut',
+ ];
+ static const _liveQuoteFields = [
+ 'depositAddress',
+ 'depositMemo',
+ 'deadline',
+ 'timeWhenInactive',
+ 'timeEstimate',
+ 'refundFee',
+ 'withdrawFee',
+ ];
+
+ /// Whether [response] carries a valid signature from [managerPublicKey].
+ static bool verify(Map response, {String managerPublicKey = AppConstants.oneClickManagerPublicKey}) {
+ final signature = response['signature'];
+ if (signature is! String) return false;
+ try {
+ final message = utf8.encode(hash(response));
+ return ed25519.verify(ed25519.PublicKey(decodeKey(managerPublicKey)), message, decodeKey(signature));
+ } on FormatException {
+ return false;
+ } on ArgumentError {
+ return false;
+ }
+ }
+
+ /// Base58 SHA-256 of the signed payload; the bytes of this string are what
+ /// the signature covers.
+ static String hash(Map response) {
+ final request = response['quoteRequest'] as Map;
+ final quote = response['quote'] as Map;
+ final signed = {
+ for (final k in _requestFields)
+ if (request[k] != null) k: request[k],
+ for (final k in _optionalRequestFields)
+ if (_truthy(request[k])) k: request[k],
+ for (final k in _quoteFields)
+ if (quote[k] != null) k: quote[k],
+ if (request['dry'] != true)
+ for (final k in _liveQuoteFields)
+ if (_truthy(quote[k])) k: quote[k],
+ if (response['timestamp'] != null) 'timestamp': response['timestamp'],
+ };
+ final digest = sha256.convert(utf8.encode(stableJson(signed)));
+ return _base58.encode(Uint8List.fromList(digest.bytes));
+ }
+
+ /// JSON with keys sorted at every level and no whitespace, as
+ /// json-stable-stringify prints it; a whole-number double prints as an
+ /// integer the way JavaScript does.
+ static String stableJson(Object? value) => switch (value) {
+ Map() =>
+ '{${(value.keys.cast().toList()..sort()).map((k) => '${jsonEncode(k)}:${stableJson(value[k])}').join(',')}}',
+ List() => '[${value.map(stableJson).join(',')}]',
+ double() when value.isFinite && value == value.truncateToDouble() && value.abs() < 1e21 => value.toInt().toString(),
+ _ => jsonEncode(value),
+ };
+
+ static Uint8List decodeKey(String value) =>
+ _base58.decode(value.startsWith(_prefix) ? value.substring(_prefix.length) : value);
+
+ static String encodeKey(Uint8List bytes) => '$_prefix${_base58.encode(bytes)}';
+
+ static bool _truthy(Object? v) => v != null && v != false && v != 0 && v != '' && !(v is double && v.isNaN);
+}
diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart
index 9d6eede2a..e45528b83 100644
--- a/quantus_sdk/lib/src/services/swap_service.dart
+++ b/quantus_sdk/lib/src/services/swap_service.dart
@@ -1,272 +1,364 @@
import 'dart:convert';
-import 'dart:math';
-import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:quantus_sdk/src/constants/app_constants.dart';
+import 'package:quantus_sdk/src/models/swap_order.dart';
+import 'package:quantus_sdk/src/models/swap_quote.dart';
+import 'package:quantus_sdk/src/models/swap_token.dart';
+import 'package:quantus_sdk/src/services/one_click_quote_signature.dart';
+import 'package:quantus_sdk/src/utils/print.dart';
import 'package:shared_preferences/shared_preferences.dart';
-enum SwapStatus { pending, depositing, processing, complete, failed, expired }
-
-class SwapToken {
- final String symbol;
- final String name;
- final String network;
- final int decimals;
- final String? iconUrl;
- final String? networkIconUrl;
-
- const SwapToken({
- required this.symbol,
- required this.name,
- required this.network,
- this.decimals = 18,
- this.iconUrl,
- this.networkIconUrl,
- });
+/// 1Click answered [statusCode] with [message].
+class SwapApiException implements Exception {
+ final int statusCode;
+ final String message;
- @override
- bool operator ==(Object other) => other is SwapToken && symbol == other.symbol && network == other.network;
+ const SwapApiException(this.statusCode, this.message);
@override
- int get hashCode => Object.hash(symbol, network);
+ String toString() => 'SwapApiException($statusCode): $message';
}
-class SwapQuote {
- final String quoteId;
- final SwapToken fromToken;
- final SwapToken toToken;
- final double fromAmount;
- final double toAmount;
- final double rate;
- final double networkFee;
- final double totalAmount;
- final double slippageTolerance;
- final Duration expiresIn;
-
- const SwapQuote({
- required this.quoteId,
- required this.fromToken,
- required this.toToken,
- required this.fromAmount,
- required this.toAmount,
- required this.rate,
- required this.networkFee,
- required this.totalAmount,
- required this.slippageTolerance,
- required this.expiresIn,
- });
-}
+/// A quote response whose signature is not 1Click's, or which answers a
+/// different request than the one sent.
+class SwapQuoteIntegrityException implements Exception {
+ final String message;
+ final String? correlationId;
-class SwapOrder {
- final String orderId;
- final SwapQuote quote;
- final String depositAddress;
- final SwapStatus status;
- final DateTime createdAt;
-
- const SwapOrder({
- required this.orderId,
- required this.quote,
- required this.depositAddress,
- required this.status,
- required this.createdAt,
- });
-
- SwapOrder copyWith({SwapStatus? status}) => SwapOrder(
- orderId: orderId,
- quote: quote,
- depositAddress: depositAddress,
- status: status ?? this.status,
- createdAt: createdAt,
- );
+ const SwapQuoteIntegrityException(this.message, {this.correlationId});
+
+ @override
+ String toString() => 'SwapQuoteIntegrityException($correlationId): $message';
}
+/// Client for the NEAR Intents 1Click API. A quote names a deposit address on
+/// the origin chain; whatever the user sends there before the deadline is
+/// swapped by solvers and paid out to the recipient, or refunded. Nothing in
+/// the app ever holds or signs the swapped funds.
class SwapService {
- static final SwapService _instance = SwapService._();
- factory SwapService() => _instance;
- SwapService._();
-
- static const _refundAddressKey = 'recent_refund_addresses';
- static const _maxRefundAddresses = 50;
- static const _intentsTokensUrl = 'https://1click.chaindefuser.com/v0/tokens';
+ static const defaultSlippageBps = 100;
+ static const depositWindow = Duration(minutes: 20);
+
+ /// Chains whose blocks take minutes and need several confirmations, so a
+ /// correctly sent deposit can take an hour to count.
+ static const _slowNetworks = {'BTC', 'LTC', 'DOGE', 'BCH', 'DASH', 'ZEC'};
+ static const _slowDepositWindow = Duration(hours: 2);
+
+ /// Least time a live quote's deposit address must stay valid, so a deposit
+ /// signed now still lands before the address goes cold.
+ static const minimumDepositLead = Duration(minutes: 5);
+
+ /// Chains whose deposits carry a memo; 1Click rejects a plain deposit
+ /// address for them and a memo request for every other chain.
+ static const _memoNetworks = {'STELLAR'};
+ static const _liveQuotesKey = 'swap_live_quotes';
+ static const _maxLiveQuotes = 50;
+
+ /// Request fields 1Click echoes back that must match what was sent, so a
+ /// signed quote is a quote for this swap and not another.
+ static const _echoedFields = [
+ 'dry',
+ 'originAsset',
+ 'destinationAsset',
+ 'amount',
+ 'refundTo',
+ 'recipient',
+ 'slippageTolerance',
+ ];
+ static const quoteWaitingTime = Duration(seconds: 3);
+ static const statusPollInterval = Duration(seconds: 5);
+ static const _savedAddressesKey = 'swap_saved_addresses';
+ static const _maxSavedAddresses = 50;
static const _coinGeckoTopUrl =
'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false';
static const _tokensCacheTtl = Duration(minutes: 10);
- final _orders = {};
+
+ final http.Client _client;
+ final Uri _base;
+ final String? _apiKey;
+ final String _managerPublicKey;
List? _cachedFromTokens;
DateTime? _cachedFromTokensAt;
- Map _liveUsdPriceBySymbol = {};
-
- static const availableTokens = [
- SwapToken(symbol: 'USDC', name: 'USD Coin', network: 'Ethereum'),
- SwapToken(symbol: 'USDT', name: 'Tether', network: 'Ethereum'),
- SwapToken(symbol: 'ETH', name: 'Ethereum', network: 'Ethereum'),
- SwapToken(symbol: 'BTC', name: 'Bitcoin', network: 'Bitcoin', decimals: 8),
- SwapToken(symbol: 'SOL', name: 'Solana', network: 'Solana', decimals: 9),
- _quToken,
- ];
-
- static const _quToken = SwapToken(symbol: AppConstants.tokenSymbol, name: 'Quantus', network: 'Quantus');
+ SwapToken? _listedQuantus;
+
+ SwapService({
+ http.Client? client,
+ String endpoint = AppConstants.oneClickEndpoint,
+ String? apiKey,
+ String managerPublicKey = AppConstants.oneClickManagerPublicKey,
+ }) : _client = client ?? http.Client(),
+ _base = Uri.parse(endpoint),
+ _apiKey = apiKey,
+ _managerPublicKey = managerPublicKey;
+
+ /// How long a deposit on [network] has before its quote expires.
+ static Duration depositWindowFor(String network) =>
+ _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow;
+
+ /// QTC from the app's own metadata, for use until 1Click lists it.
+ static SwapToken quantusToken({required double usdPrice}) => SwapToken(
+ assetId: AppConstants.quantusIntentsAssetId,
+ symbol: AppConstants.tokenSymbol,
+ network: 'Quantus',
+ decimals: AppConstants.decimals,
+ usdPrice: usdPrice,
+ );
- Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async {
- final now = DateTime.now();
- if (!forceRefresh &&
- _cachedFromTokens != null &&
- _cachedFromTokensAt != null &&
- now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) {
- return _cachedFromTokens!.take(limit).toList();
+ Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async =>
+ (await _tokens(forceRefresh: forceRefresh)).take(limit).toList();
+
+ /// QTC as 1Click lists it, with its asset id, decimals and price; null until
+ /// it is listed. A listing whose decimals differ from the chain's would make
+ /// every quoted amount wrong on chain, so it is refused.
+ Future getListedQuantusToken({bool forceRefresh = false}) async {
+ await _tokens(forceRefresh: forceRefresh);
+ final listed = _listedQuantus;
+ if (listed != null && listed.decimals != AppConstants.decimals) {
+ throw StateError(
+ '1Click lists ${listed.assetId} with ${listed.decimals} decimals, the chain has ${AppConstants.decimals}',
+ );
}
+ return listed;
+ }
- try {
- final intentTokens = await _fetchNearIntentsTokens();
- if (intentTokens.isNotEmpty) {
- final ranked = await _rankByCoinGecko(intentTokens);
- _cachedFromTokens = ranked;
- _cachedFromTokensAt = now;
- _liveUsdPriceBySymbol = {for (final token in ranked) token.symbol.toUpperCase(): token.price};
- return ranked.take(limit).toList();
- }
- } catch (_) {}
-
- final fallback = availableTokens.where((t) => t.symbol != AppConstants.tokenSymbol).take(limit).toList();
- _cachedFromTokens = fallback;
+ Future> _tokens({required bool forceRefresh}) async {
+ final now = DateTime.now();
+ final cached = _cachedFromTokens;
+ if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) return cached;
+ final tokens = await _rankByCoinGecko(await _fetchIntentsTokens());
+ _cachedFromTokens = tokens;
_cachedFromTokensAt = now;
- return fallback;
+ return tokens;
}
- SwapToken getQuToken() => _quToken;
+ /// Asks solvers for a price on [amount] base units of [from]. A dry quote is
+ /// a preview; a live one reserves a deposit address until its deadline.
+ Future getQuote({
+ required SwapToken from,
+ required SwapToken to,
+ required BigInt amount,
+ required String refundAddress,
+ required String recipient,
+ int slippageBps = defaultSlippageBps,
+ bool dry = true,
+ }) async {
+ final json = await _quoteJson(
+ from: from,
+ to: to,
+ amount: amount,
+ refundAddress: refundAddress,
+ recipient: recipient,
+ slippageBps: slippageBps,
+ dry: dry,
+ );
+ return SwapQuote.fromJson(json, fromToken: from, toToken: to);
+ }
- static String formatTokenAmount(double amount, SwapToken token) {
- if (amount == 0) return '0';
- final decimals = token.decimals.clamp(0, 12);
- var s = amount.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), '');
- if (s.endsWith('.')) s = s.substring(0, s.length - 1);
- return s;
+ /// Re-quotes [quote] live so 1Click reserves a deposit address for it. The
+ /// signed response is kept on the device: 1Click settles any dispute about
+ /// a deposit address from it.
+ Future createSwap(SwapQuote quote) async {
+ final json = await _quoteJson(
+ from: quote.fromToken,
+ to: quote.toToken,
+ amount: quote.amountIn,
+ refundAddress: quote.refundAddress,
+ recipient: quote.recipient,
+ slippageBps: quote.slippageBps,
+ dry: false,
+ );
+ final live = SwapQuote.fromJson(json, fromToken: quote.fromToken, toToken: quote.toToken);
+ if (live.depositAddress == null) throw StateError('Live quote ${live.correlationId} has no deposit address');
+ final lead = live.deadline.difference(DateTime.now());
+ if (lead < minimumDepositLead) {
+ throw SwapQuoteIntegrityException(
+ 'Deposit deadline ${live.deadline.toIso8601String()} leaves ${lead.inSeconds}s, need $minimumDepositLead',
+ correlationId: live.correlationId,
+ );
+ }
+ await _saveLiveQuote(json);
+ return SwapOrder(quote: live, status: SwapStatus.pendingDeposit);
}
- static String formatTokenAmountHint(SwapToken token) {
- if (token.decimals == 0) return '0';
- return '0.${'0' * token.decimals.clamp(1, 8)}';
+ /// Signed live quote responses, most recent first.
+ Future>> getSavedLiveQuotes() async {
+ final prefs = await SharedPreferences.getInstance();
+ return [for (final s in prefs.getStringList(_liveQuotesKey) ?? []) jsonDecode(s) as Map];
}
- String? getTokenIconUrl(SwapToken token) {
- final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull;
- if (cached?.iconUrl != null && cached!.iconUrl!.isNotEmpty) return cached.iconUrl;
- return _fallbackTokenIconUrl(token.symbol);
+ Future _saveLiveQuote(Map json) async {
+ final prefs = await SharedPreferences.getInstance();
+ final saved = [jsonEncode(json), ...?prefs.getStringList(_liveQuotesKey)];
+ await prefs.setStringList(_liveQuotesKey, saved.take(_maxLiveQuotes).toList());
}
- String? getNetworkIconUrl(SwapToken token) {
- final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull;
- if (cached?.networkIconUrl != null && cached!.networkIconUrl!.isNotEmpty) return cached.networkIconUrl;
- return _networkIconUrl(token.network);
+ Future