Skip to content

Swap in both directions through NEAR Intents 1Click - #675

Merged
n13 merged 12 commits into
mainfrom
n13/swap-out
Sep 28, 2026
Merged

n13 merged 12 commits into
mainfrom
n13/swap-out

Conversation

@n13

@n13 n13 commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

Swap now works both ways between QTC and tokens on other chains, following the Figma "Swap Out flow". It builds on the 1Click integration from #664: that PR's three commits are included, with main merged in, so this PR supersedes #664.

  • One form for both directions. One side is QTC in the account, the other is a token on another chain ("A wallet you control"). The arrows flip the direction. Both token buttons open the token picker; picking from the QTC side flips the direction.
  • Address sheet: recipient address when swapping out, refund address when swapping in. It has saved addresses per network, QR scan and "Save for future swaps", and Continue fetches the quote.
  • Review Swap is a full screen: rate, recipient or refund address, network fee (swap out), slippage allowance, guaranteed minimum.
  • Slippage selector: the pencil next to "Slippage 1%" opens a sheet with 0.5 / 1 / 2 / 3%. The pick goes into every quote.
  • Confirm takes a live quote. If it guarantees less than the reviewed quote, the new terms replace the old and need a second confirm. Swapping out, the app then asks for device auth and sends the QTC to the deposit address through the regular transfer path. It then posts the hash to /v0/deposit/submit.
  • One progress screen follows the order through swapOrderProvider. For swap in it first shows the deposit address. Then it shows the steps, the progress bar and a details sheet, and ends on Swap Complete or Swap Failed (refunded, with Start a New Swap and Contact Support).
  • Swap is enabled only for transparent accounts that sign in the app. Encrypted, Keystone and external accounts see the button disabled.
  • Mainnet migration debug picker removed. Debug builds no longer show the migration notice on every launch.

Review findings from #664, fixed here

  1. The live quote's minimum is checked against the reviewed one before any deposit address is used.
  2. The deposit amount shows at full token precision (formatExactAmount).
  3. parseDecimal sizes its input cap by the token's decimals, so 24-decimal amounts parse.

Blocker: QTC is not listed on NEAR Intents

Right now every quote goes to the real 1Click API with the placeholder asset id nep141:qtc.omft.near. The token list loads (197 assets, none Quantus), prices and estimates work, but every quote fails with tokenOut is not valid (swap in) or tokenIn is not valid (swap out). The app shows that as an error toast in the address sheet. Nothing is sent. The home swap card follows the enableSwap remote config flag, which is currently true in production.

Release gating

Swap ships behind the enableSwap remote config flag. Real swaps cannot be tested until QTC is listed on NEAR Intents. Set enableSwap to false in production before a build with this PR goes out, and turn it back on only after swaps have been tested against the live listing. The same flag also hides the demo swap in builds already released.

Where this differs from Figma

  • The small USD/token toggle next to the dollar amount is left out.
  • The progress footer says the swap finishes even if you leave the screen, not "we'll notify you". There are no swap notifications.
  • Orders are not persisted: closing the progress screen loses tracking in the app, but not the swap.

Verification

  • melos run analyze: no issues in all four packages. melos run format is clean.
  • Mobile suite: 520 tests pass, including the new test/screens/swap_flow_test.dart. It covers the form's direction and balance states, slippage going into the quote, the swap-out confirm sending to the live deposit address, the price-moved re-confirm, the insufficient balance block, and the complete, refunded and deposit views.
  • SDK suite: 584 tests pass, including new tests for deposit submit, origin hashes, saved addresses and token helpers.
  • iPhone 17 Pro simulator: the form, address sheet, slippage sheet and live 1Click error were checked against real APIs. The review screen (with the real chain fee), progress, details, complete and failed screens were checked with canned quotes, since no real quote is possible yet.
  • No funded swap was done.

n13 added 8 commits September 21, 2026 00:44
SwapService is now a 1Click client: token list, dry quote, live quote
with a deposit address, and status by deposit address. The mock quotes,
fake deposit address, hard-coded prices and the silent token fallback
are gone. Server rejections surface as SwapApiException with the
server's message. The HTTP client and partner API key are injectable.

SwapToken carries the 1Click asset id and USD price. SwapQuote and
SwapOrder hold base-unit amounts as BigInt. NumberFormattingService
takes a decimals count so every token reuses the locale-aware parser
and formatter.

QTC is not listed on NEAR Intents yet, so quantusIntentsAssetId is a
placeholder and every quote into it fails with "tokenOut is not valid".
Tests cover the request shape, response parsing, all statuses and the
error paths against a mock client.
The swap screen loads real tokens, quotes with the active account as
recipient, and shows server errors as a toast. The review sheet shows
the quoted amount out, minimum received and time estimate, then asks
for a live quote on Confirm. The deposit screen shows the real deposit
address and deadline, polls status every 5 seconds, and has a view for
each status plus an expired one. Demo strings are removed, English and
Indonesian updated. The home swap button stays behind showSwapButton.
# Conflicts:
#	mobile-app/lib/v2/screens/swap/review_quote_sheet.dart
#	mobile-app/lib/v2/screens/swap/swap_screen.dart
#	quantus_sdk/lib/src/services/swap_service.dart
Debug builds no longer show the migration notice on every launch with a
testnet outcome picker. The notice now only shows when it is due, in
debug and release alike. Drops AppConstants.debugMainnetMigration, the
forced-outcome provider and the picker's test.
SwapToken knows whether it is the Quantus token and names its network
("Ethereum" for ETH). SwapOrder reads the origin chain transaction
hashes. SwapService tells 1Click which transaction paid a deposit
address (POST /v0/deposit/submit) and keeps saved addresses per network
instead of recent refund addresses.

LocaleNumberConfig.parseDecimal sizes its input cap by the token's
decimals, so a 24-decimal amount parses instead of returning null.
NumberFormattingService.formatExactAmount prints every significant
digit for amounts someone must send exactly. QuantusTextField can keep
room for a trailing widget wider than one icon button.
The swap form holds QTC in the account on one side and a token on
another chain on the other; the arrows flip the direction. An address
sheet asks for the recipient (swap out) or refund address (swap in),
with saved addresses and QR scan, and quotes the swap. Review Swap is a
full screen with rate, address, network fee, slippage allowance and
guaranteed minimum. The slippage tolerance is picked from 0.5, 1, 2 and
3 percent.

Confirming takes a live quote. When it guarantees less than the one
reviewed, the new terms replace the old and need a second confirm.
Swapping out, the app then signs a transfer of the QTC to the deposit
address with the account's own key, using the regular send path, and
hands the hash to 1Click.

One progress screen follows the order: the deposit address while a
swap in waits, then the steps, a details sheet, and Swap Complete or
Swap Failed. The deposit amount shows at full precision.

Swap is enabled only for transparent accounts that sign in the app.
@n13 n13 added the bot-review Request automated review from review-bot label Sep 25, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer model: GPT-6 Sol

Verdict (advisory): Request changes

Reviewed head 3e6344143974b842708b9fc5c28022ad34c6e5cc against base 900fa77912e74b00fe60678b48e6f61b556e2bdd.

  1. [P1] Show required deposit memos for swap-in quotes. swap_progress_screen.dart:413 encodes only the address in the QR, and the screen's copy/share actions also provide only the address. Yet SwapQuote retains depositMemo, and 1Click's SDK says some origin-chain deposits require that memo to be processed. The token list includes Stellar XLM, for which the SDK describes memo-mode deposits. If a live quote has a memo, following this screen's instructions sends an incomplete deposit and risks stranded funds. Display and separately copy the memo, include it in sharing instructions, and test a memo-bearing live quote; otherwise reject that route before exposing the deposit address.

  2. [P1] Allow enough deposit time for slow origin chains. swap_service.dart:28 gives every quote a 20-minute deadline, while BTC is selectable from the live token list. 1Click's quote contract says the deadline must exceed origin-chain mining time and gives Bitcoin as an example that may require about an hour. A correctly sent BTC deposit can therefore reach confirmation after the quote's deadline and be refunded or delayed, with refund fees or loss exposure. Use a chain-appropriate window or exclude chains for which this deadline cannot be met.

  3. [P2] Show the exact outgoing amount on Review Swap. swap_summary.dart:84 calls formatAmount, which truncates ordinary values to four fractional digits. For example, a quoted payment of 1.00009 QTC appears as 1 QTC on the confirmation screen while submitLocal transfers the full 1.00009 QTC. Use the existing formatExactAmount for the pay side of the review card so the amount authorized is the amount shown.

Validation: git diff --check passed; formatting of 14 touched Dart source files made no changes; 18 focused SDK tests and 107 focused mobile widget/locale tests passed. GitHub's Analyze check passed for this head. No funded swap could be tested because QTC is not yet listed on 1Click; the production enableSwap flag must be disabled before release as the PR description notes.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 25, 2026
Address the review on the swap-out PR. A live quote's depositMemo is
now shown on the deposit screen with its own copy action, a warning
that a deposit without it is not processed, and a place in the shared
text. Deposits from BTC, LTC, DOGE, BCH, DASH and ZEC get a two-hour
window instead of twenty minutes, since their confirmations can take an
hour. The pay side of the swap card and the details sheet print every
digit of the amount that is sent.

Once 1Click lists QTC under AppConstants.quantusIntentsAssetId, its
asset id, decimals and price take over from the app's own QTC metadata.
A listing whose decimals differ from the chain's is refused, since every
quoted amount would then be wrong on chain.
@n13

n13 commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in cb52d04:

  1. Deposit memos. A live quote's depositMemo now shows on the deposit screen under the address, with its own copy action and a warning that a deposit without it is not processed. The shared text carries it too. The QR still encodes only the address, since a memo has no address-QR form that every wallet reads. Covered by a Stellar memo case in swap_flow_test.dart.
  2. Deposit window. SwapService.depositWindowFor(network) gives BTC, LTC, DOGE, BCH, DASH and ZEC two hours; everything else keeps twenty minutes. SDK test checks a BTC quote's deadline.
  3. Exact outgoing amount. The pay side of the swap card (review, progress, failed) and the details sheet print every digit with formatExactAmount. Test: 1.00009 QTC shows as such on Review Swap.

Also in the same commit: once 1Click lists QTC under AppConstants.quantusIntentsAssetId, its decimals and price take over from the app's own metadata, and a listing with other decimals is refused.

…ures

Checked against the 1Click OpenAPI spec (v0.1.10) and live dry quotes.
Stellar refuses a quote unless depositMode is MEMO, and every other
chain refuses MEMO, so the request names the mode per origin chain.
Every quote carries referral "quantus", the distribution channel id
1Click records. SwapQuote keeps 1Click's signature over the quote, and
the signed response of every live quote is saved on the device, which
the spec requires for settling a dispute about a deposit address.
@n13

n13 commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

3e46fc1 checks the integration against the 1Click OpenAPI spec (v0.1.10, https://1click.chaindefuser.com/docs/v0-json) and live dry quotes:

  • depositMode. Stellar refuses a quote without depositMode: MEMO ("Incorrect depositMode for originAsset from stellar chain"); XRP, TON, ETH and SOL refuse MEMO. The request now sends MEMO for a Stellar origin and SIMPLE otherwise. XRP carries its tag inside X-addresses, per the chain-support page.
  • referral: quantus on every quote, the distribution channel id 1Click records. Echoed back in live probes.
  • Signatures. The spec says the signed quote "must be saved on the client side ... to resolve any disputes". SwapQuote now keeps signature, and createSwap stores the full signed response of every live quote on the device (SwapService.getSavedLiveQuotes, last 50).
  • Already matching: X-API-Key header (the recommended scheme), integer base-unit amounts, EXACT_INPUT with origin-chain deposit and destination-chain payout, depositMemo passed to /v0/status and /v0/deposit/submit, all seven status values, error message handling.

Open, not in this PR: the about page says users must accept the 1Click Terms of Service; the app shows no such notice yet. Signature verification needs 1Click's public key, which the docs page does not give; the TypeScript SDK does it. Unauthenticated quotes carried a 20 bps appFee in today's probes, 25 per the docs.

Every quote response now has to carry 1Click's Ed25519 signature over
the request and quote fields, checked the way verifyQuoteSignature in
@defuse-protocol/one-click-sdk-typescript does it: key-sorted JSON of
the signed fields plus the timestamp, SHA-256, base58, then the
signature over the bytes of that string against the 1Click manager key.
The echoed quoteRequest must also repeat the amount, assets, addresses,
slippage and dry flag that were sent. Either failing throws
SwapQuoteIntegrityException before the quote is shown or a deposit
address is used.

The SDK's staging fixtures verify byte for byte against its staging key.
Test fakes sign their responses with a generated key. Adds crypto,
ed25519_edwards and base_x as direct SDK dependencies; all three were
already in the lock file through polkadart.
@n13

n13 commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

fd45f10 verifies quote signatures.

Neither reference wallet does this: Vizor (chainapsis/vizor-wallet, Flutter) hard-codes depositMode: SIMPLE, uses a 2-hour deadline and compares the echoed quoteRequest with what it sent; Zodl Android sends referral: "zodl" and a 2-hour deadline. The only implementation is verifyQuoteSignature in @defuse-protocol/one-click-sdk-typescript, so OneClickQuoteSignature is a port of it:

  • signed payload = the fixed request fields (dry, swapType, slippageTolerance, assets, amount, addresses, types, deadline, plus quoteWaitingTimeMs, referral and the virtual-chain fields when set) merged with the quote's amounts (plus depositAddress, depositMemo, deadline, timeWhenInactive, timeEstimate, refundFee, withdrawFee on a live quote) and timestamp;
  • JSON with keys sorted at every level and no whitespace, as json-stable-stringify prints it; SHA-256; base58; Ed25519 over the bytes of that string against ed25519:reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc (AppConstants.oneClickManagerPublicKey).

SwapService refuses a quote whose signature does not verify, and, following Vizor, one whose echoed quoteRequest differs from what was sent in amount, assets, addresses, slippage or dry. Both throw SwapQuoteIntegrityException before anything is shown or sent.

Tests: the SDK's staging fixtures (live, dry, and the status-endpoint variant with null routing fields) verify byte for byte against its staging key; a tampered deposit address, the wrong key and malformed signatures are rejected; a signed quote answering a different request is rejected. Mock 1Click servers in both test suites now sign their responses with a generated key. crypto, ed25519_edwards and base_x become direct SDK dependencies (already in the lock through polkadart). Analyzer clean; mobile 522 and SDK 595 tests pass.

@n13 n13 added the bot-review Request automated review from review-bot label Sep 26, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer model: GPT-6 Sol

Verdict (advisory): Request changes

Reviewed head fd45f10d072eaceb985c3114ada8abb6aa6983c2 against base 900fa77912e74b00fe60678b48e6f61b556e2bdd. The three findings from the previous review (deposit memo, slow-chain deadline, exact outgoing display) are addressed.

  1. [P1] Refuse an expired live quote before transferring QTC. SwapService.createSwap requires a deposit address but does not check the signed quote's deadline. ReviewSwapScreen only checks the deadline when reusing a previously held order; it can immediately transfer to a newly returned expired address. The createSwap test currently passes with a live deadline of 2026-09-20, already past on this review date. A stale or replayed signed response can therefore direct funds to an inactive deposit address. Verify the echoed request deadline and require a live deposit deadline with enough time for signing and inclusion before sending.

  2. [P1] Do not silently increase the amount sent after confirmation. ReviewSwapScreen._liveOrder compares only minAmountOut. The reviewed card and affordability check use _quote.amountIn, but the transfer uses order.quote.amountIn from the live response. The echo check verifies quoteRequest.amount, not quote.amountIn. A signed live response with a larger amountIn and an unchanged or better minimum sends more QTC than the user reviewed. Require the live input amount to equal the reviewed amount, or show the changed amount for a second confirmation and repeat the balance and fee checks.

Validation: git diff --check passed; dart format --output=none --set-exit-if-changed changed no files; 29 focused SDK tests and 109 focused mobile tests passed; GitHub Analyze passed at this head. No funded swap was possible because QTC is not yet listed on 1Click. Disable the production enableSwap flag before releasing this feature, as the PR description notes.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 26, 2026
A quote now has to price exactly the amount sent (quote.amountIn equals
the request amount) and echo the deadline that was sent, on top of the
signature and the other echoed fields, so a swap out transfers the
amount the user reviewed and nothing else. createSwap refuses a live
quote whose deposit address has less than five minutes left, so a stale
or replayed signed response can no longer direct a deposit to an
address that is about to go cold. The review screen reuses the same
lead when deciding whether to keep a live order for a retry.

The request deadline goes out at millisecond precision so the echo can
be compared as an instant.
@n13

n13 commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Both addressed in 47e99a1.

  1. Expired live quote. createSwap now refuses a live quote whose deposit deadline is less than SwapService.minimumDepositLead (5 minutes) away, before anything is saved or sent. The review screen reuses the same lead when deciding whether a held order can be retried. The echoed quoteRequest.deadline is also checked against the deadline sent (compared as instants; the request goes out at millisecond precision). The createSwap fixture now carries a live deadline 30 minutes ahead instead of 2026-09-20, and a new test feeds a deadline one minute out and expects SwapQuoteIntegrityException.

  2. Amount sent. A quote must price exactly the amount sent: quote.amountIn has to equal the request amount, dry or live. createSwap requests the reviewed amountIn, so the live amountIn the transfer uses is the reviewed amount by construction; a signed response pricing a larger input is refused. New tests: a response with amountIn changed and re-signed is rejected, as is one echoing another deadline.

Analyzer clean; mobile 522 and SDK 598 tests pass.

@n13 n13 added the bot-review Request automated review from review-bot label Sep 26, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer model: GPT-6 Sol

Verdict (advisory): Approve

Reviewed head 47e99a10d425e2dced59ad11689623071a5628b1 against base 900fa77912e74b00fe60678b48e6f61b556e2bdd. No blocking code findings remain. The latest commit checks that the signed live quote prices the requested input amount, echoes the requested deadline, and leaves at least five minutes before the deposit address expires. This addresses the two findings in my previous review; the earlier memo, slow-chain window, and exact-pay-display findings are also addressed.

Validation: git diff --check passed; formatting of 28 existing changed Dart files made no changes; 32 focused SDK tests and 110 focused mobile tests passed. GitHub's Analyze check passed for this exact head. A dry quote probe from this host received HTTP 403, and a funded end-to-end swap remains untested because QTC is not yet listed on 1Click.

Release gate: keep enableSwap off in production before shipping a build with this PR. Enable it only after QTC is listed and both swap directions have been tested against live quotes and funded transfers, as the PR description notes.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 26, 2026

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@n13
n13 merged commit d159227 into main Sep 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants