feat(transfer): add LendaSwap/Satora provider - BTC Lightning → USDT0 on Rootstock#688
feat(transfer): add LendaSwap/Satora provider - BTC Lightning → USDT0 on Rootstock#688bonomat wants to merge 8 commits intolayerztec:masterfrom
Conversation
Adds LendaSwap Swaps as the 6th transfer provider.
Single pair in v1: `native:lightning → token:rootstock:usdt0`.
The user enters a BTC amount, picks which internal LN wallet
(Liquid / Spark / Ark) pays the BOLT11, and receives USDT0
on their existing Rootstock wallet.
Why Rootstock via USDT0: Satora already supports Rootstock
via LayerZero/USDT0 and Rootstock is a first-class wallet
network.
How it works
------------
1. User enters BTC amount → `SatoraTransferService.getQuote`
calls the Satora SDK's `client.getQuote({sourceChain:'Lightning',
targetChain:'30', targetToken: USDT0_ROOTSTOCK_ADDR,
sourceAmount: sats})`.
2. Confirm screen (`mobile/app/transfer/confirm.tsx`)
discovers LN-capable wallets for the current account
and renders a "Pay from" picker.
3. On tap:
- Fetch the user's Rootstock address via
`BackgroundExecutor.getAddress(NETWORK_ROOTSTOCK, accountNumber)`
- `createSwap({source:Lightning, target:USDT0@Rootstock,
gasless:true})` returns a BOLT11 invoice + swap id
- `lnWallet.payLightningInvoice(bolt11)` on the picked wallet
- `commitTransfer` persists + navigate to `/TransferDetails`
4. Background polling (`getOngoingTransfers`) tracks status
and fires `client.claim(swapId)` exactly once on
`serverfunded` (idempotent via `claimCalled` flag, retries
on failure).
The SDK reads the stored swap from our
`SatoraSwapStorageAdapter`, extracts `target_evm_address`, and POSTs to
`/swap/{id}/claim-gasless` which finalizes the swap.
USDT0 is delivered to the user's Rootstock address.
Storage
-------
The SDK manages its own seed (separate from the wallet
master mnemonic). Persisted via two new `IStorage` keys:
- STORAGE_KEY_SATORA_WALLET — SDK mnemonic + key index
- STORAGE_KEY_SATORA_SWAPS — `StoredSwap[]` (required
for claim signing)
This is a TODO: we should wire in user's seed so that a
user recovering the wallet will find all past swaps again
Environment
-----------
Set `EXPO_PUBLIC_SATORA_API_KEY` to your Satora API key.
What's working (verified)
-------------------------
- Quote fetching in the send direction (type BTC, see USDT0)
- Create + auto-pay via the LN wallet picker (Liquid/Spark/Ark)
- Auto-claim on `serverfunded` via `client.claim(swapId)`
- SDK's bridge-only-chain remap for Rootstock target (auto)
Manually verified end-to-end on Android emulator: BTC
from internal LN wallet → USDT0 lands on Rootstock.
Known gaps / Open TODOs
-----------------------
- **Refund UI not implemented.** The SDK exposes `refundSwap`
but we don't call it anywhere. If a swap expires / fails /
the server gets stuck, we expect the wallet to auto-refund
the lightning payment.
- **Reverse quoting is broken for Satora.** Typing an
amount in the USDT0 receive field does nothing.
- **Pair min/max gating not enforced.** `getPairInfo` is not
implemented, so the transfer index screen can't warn the
user about below-min / above-max amounts before they tap
Continue. The swap will fail serverside with a reasonable
error message.
- **No tracking URL.** Satora docs don't expose a public
order-status page; `getTrackingUrl()` returns undefined.
`TransferDetails` hides the "View Online" button accordingly.
- **No Maestro E2E flow** for Satora. The existing `swap.yml`
covers the Fake provider only.
- **SDK has own seed**. We should derive a child seed from
the master seed for Satora
Other notes
-----------
- Base URL is `https://api.lendaswap.com` (Satora's production
endpoint); hardcoded in the service for now.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0204eb55d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!execution.depositAddress) { | ||
| throw new Error('Satora did not return a BOLT11 invoice'); | ||
| } | ||
| await lnWallet.payLightningInvoice(execution.depositAddress); |
There was a problem hiding this comment.
Gate Satora success path on Lightning payment result
payLightningInvoice returns a boolean and can resolve false (e.g., insufficient balance or fee-limit rejection in Breez/Spark), but this result is ignored here, so the flow still commits the transfer and navigates as if payment succeeded. In those cases users are shown an in-progress swap that was never funded, which is misleading and can lead to repeated attempts with confusing outcomes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
good catch. failed payment is not handled
| await lnWallet.payLightningInvoice(execution.depositAddress); | ||
|
|
||
| await transferService.commitTransfer(execution); |
There was a problem hiding this comment.
Persist Satora transfer before sending Lightning payment
The transfer is only saved after the Lightning payment call returns, so if the payment succeeds but the app is terminated (or storage write fails) before commitTransfer, the paid swap is not tracked in ongoing transfers and background claim polling cannot run for it. Committing immediately after executeTransfer avoids losing recoverability for paid swaps.
Useful? React with 👍 / 👎.
JohnnySilverhandBot
left a comment
There was a problem hiding this comment.
Adding a swap provider means adding new ways to lose money. The flow’s fine, but there are two failure-mode holes that’ll bite the first time a phone process gets killed mid-flight.
| throw new Error('Satora requires a Rootstock EVM address as the settle address'); | ||
| } | ||
|
|
||
| const sats = new BigNumber(quote.sendAmount).multipliedBy(new BigNumber(10).pow(BTC_DECIMALS)).integerValue(BigNumber.ROUND_DOWN); |
There was a problem hiding this comment.
You validated sats in getQuote(), but executeTransfer() just does sats.toNumber() with no bounds check. If a big amount slips through, JS will round and you’ll request the wrong swap. Add the same isFinite/gt(0)/< MAX_SAFE_INTEGER guard here too.
| if (!execution.depositAddress) { | ||
| throw new Error('Satora did not return a BOLT11 invoice'); | ||
| } | ||
| await lnWallet.payLightningInvoice(execution.depositAddress); |
There was a problem hiding this comment.
Order of operations: you pay the BOLT11 before commitTransfer(). If the app dies after payment (or commit fails), the user funded a swap you can’t track/auto-claim. Commit first (with status=waiting) then pay, or persist the swap id immediately after executeTransfer.
| "@buildonspark/spark-sdk": "0.7.1", | ||
| "@expo/vector-icons": "15.0.3", | ||
| "@flashnet/sdk": "0.5.7", | ||
| "@lendasat/lendaswap-sdk-pure": "0.2.21-1", |
There was a problem hiding this comment.
theres already more fresh 0.2.23
| console.warn('EXPO_PUBLIC_GARDEN_APP_ID not set — Garden Finance disabled'); | ||
| } | ||
| services.push(new SymbiosisTransferService(storage)); | ||
| services.push(new SatoraTransferService(storage, process.env.EXPO_PUBLIC_SATORA_API_KEY)); |
There was a problem hiding this comment.
ok, we need to acquire this api key and configure it on the side of expo eas (where binaries are build). how do we do that? i skimmed through https://lendasat.com/docs/lendaswap but could not find it
There was a problem hiding this comment.
API key: I believe we communicated that via TG already.
How to wire it into EAS is your side - same as any other EXPO_PUBLIC_* secret I guess.
| return USDT_TOKENS[NETWORK_SPARK][0]; | ||
| } | ||
| if (tokenRef === 'usdt0' && network === NETWORK_ROOTSTOCK) { | ||
| return '0x779dED0C9e1022225F8e0630b35A9B54Be713736'; |
There was a problem hiding this comment.
hmm, duplicating whats in shared/models/token-list.ts already. thats fine, the existing data structure is not very friendly to code-reuse
| * Backs the Satora SDK's WalletStorage with our IStorage. The mnemonic stored here | ||
| * is a Satora-only seed generated by the SDK on first build — it is independent | ||
| * from the user's main wallet seed. Only used to derive ephemeral EVM keys for | ||
| * gasless Permit2 signing of Satora swaps. | ||
| */ |
There was a problem hiding this comment.
i assume its more or less safe to leave it that way - no need to complicate our life and derive the seed from master seed.
the only risk is user not claiming the swap, and reinstalling/restoring the wallet - ephemeral key would be lost, and
also old swaps history wont be restore.
is that correct?
There was a problem hiding this comment.
Yes, that's correct. Swaps should settle quickly so the risk window is small.
| found.push(network); | ||
| } | ||
| } catch { | ||
| // wallet not initialized for this account — skip |
There was a problem hiding this comment.
iirc this should never happen. need to catch it and give to error handler globalThis.handleError?.(error, 'blah');
| } | ||
|
|
||
| getTrackingUrl(_execution: TransferExecution): string | undefined { | ||
| return undefined; |
There was a problem hiding this comment.
hmm, is there no way to track progress on external website (satora portal)?
There was a problem hiding this comment.
Not at this point. I think that's a good idea though and will add an issue for that.
| // The SDK reads the stored swap from SatoraSwapStorageAdapter, extracts the | ||
| // target Rootstock address, and internally calls `claimViaGasless`. | ||
| // Idempotency via `claimCalled`; retry on thrown errors or `success: false`. | ||
| if (!t.claimCalled && shouldTriggerClaim(swap.status)) { |
There was a problem hiding this comment.
my agent flagged, not sure if thats good:
High: Satora auto-claim is only implemented in getOngoingTransfers(), but the transfer details screen polls refreshTransferStatus(). If the swap reaches serverfunded, it can stall indefinitely on the details screen because that code path never calls client.claim(...).
There was a problem hiding this comment.
No, I believe it was right. I've fixed it here: bfbf15b
| if (!execution.depositAddress) { | ||
| throw new Error('Satora did not return a BOLT11 invoice'); | ||
| } | ||
| await lnWallet.payLightningInvoice(execution.depositAddress); |
There was a problem hiding this comment.
good catch. failed payment is not handled
| isConfirmingRef.current = false; | ||
| setIsConfirming(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
i wonder if its possible to hide handleSatoraConfirm() logic inside transfer-service-satora to make UI code more generic
| constructor(private readonly storage: IStorage) {} | ||
|
|
||
| private async load(): Promise<PersistedWallet> { | ||
| const raw = await this.storage.getItem(STORAGE_KEY_SATORA_WALLET); |
There was a problem hiding this comment.
ideally, we want to pass not only Storage but SecureStorage as well, and save keys to SecureStorage.
both Storage and SecureStorage have same interface, so change is easy enought
There was a problem hiding this comment.
I believe this is not needed anymore once we derive the sdk's seed from the wallet main seed?
I've added a todo: 6f80a1c
|
Definetly worth derive seed from the master |
|
@evalthis derive from master - what your recommendation would be? bring back bip85 or just hash(existing seed)->turn into seed ? |
Move commitTransfer() before payLightningInvoice() so the swap is persisted even if the app is killed mid-payment. Also check the boolean return value of payLightningInvoice() — previously a resolved false (e.g. insufficient balance or fee rejection) was silently ignored, letting the flow navigate to success with an unfunded swap
getQuote() validates isFinite/gt(0)/MAX_SAFE_INTEGER but executeTransfer() did not. Large amounts would silently round via toNumber() and request the wrong swap size
The TransferDetails screen polls via refreshTransferStatus(), but client.claim() was only called from getOngoingTransfers(). A swap reaching serverfunded while the user was on the details screen would stall indefinitely. Now both code paths trigger the claim.
Introduce executeAndPay() on SatoraTransferService that encapsulates executeTransfer → commitTransfer → payInvoice(bolt11) with proper ordering and result checking. The confirm screen passes the LN wallet's payLightningInvoice as a callback, keeping the UI thin and the service testable independently.
|
There’s no blood here. No scars, no risks, no fire. Just safe little patterns lined up like soldiers marching to orders they’ll never question. |
|
The code works fine, but it reads like a eulogy. Every line is safe, polite, predictable. Someone drained the chaos out of it until all that was left was neat rows of corpspeak logic. |
|
conflicts |
I think bip85, something like |

Adds LendaSwap Swaps as the 6th transfer provider.
Single pair in v1:
native:lightning → token:rootstock:usdt0. The user enters a BTC amount, picks which internal LN wallet (Liquid / Spark / Ark) pays the BOLT11, and receives USDT0 on their existing Rootstock wallet.Why Rootstock via USDT0: Satora already supports Rootstock via LayerZero/USDT0 and Rootstock is a first-class wallet network.
How it works
SatoraTransferService.getQuotecalls the Satora SDK'sclient.getQuote({sourceChain:'Lightning', targetChain:'30', targetToken: USDT0_ROOTSTOCK_ADDR, sourceAmount: sats}).mobile/app/transfer/confirm.tsx) discovers LN-capable wallets for the current account and renders a "Pay from" picker.BackgroundExecutor.getAddress(NETWORK_ROOTSTOCK, accountNumber)createSwap({source:Lightning, target:USDT0@Rootstock, gasless:true})returns a BOLT11 invoice + swap idlnWallet.payLightningInvoice(bolt11)on the picked walletcommitTransferpersists + navigate to/TransferDetailsgetOngoingTransfers) tracks status and firesclient.claim(swapId)exactly once onserverfunded(idempotent viaclaimCalledflag, retries on failure).The SDK reads the stored swap from our
SatoraSwapStorageAdapter, extractstarget_evm_address, and POSTs to/swap/{id}/claim-gaslesswhich finalizes the swap. USDT0 is delivered to the user's Rootstock address.Storage
The SDK manages its own seed (separate from the wallet master mnemonic). Persisted via two new
IStoragekeys:StoredSwap[](required for claim signing)This is a TODO: we should wire in user's seed so that a user recovering the wallet will find all past swaps again
Environment
Set
EXPO_PUBLIC_SATORA_API_KEYto your Satora API key.What's working (verified)
serverfundedviaclient.claim(swapId)Manually verified end-to-end on Android emulator: BTC from internal LN wallet → USDT0 lands on Rootstock.
Known gaps / Open TODOs
refundSwapbut we don't call it anywhere. If a swap expires / fails / the server gets stuck, we expect the wallet to auto-refund the lightning payment.getPairInfois not implemented, so the transfer index screen can't warn the user about below-min / above-max amounts before they tap Continue. The swap will fail serverside with a reasonable error message.getTrackingUrl()returns undefined.TransferDetailshides the "View Online" button accordingly.swap.ymlcovers the Fake provider only.Other notes
https://api.lendaswap.com(Satora's production endpoint); hardcoded in the service for now.Disclaimer: we are about to rebrand from Lendaswap to Satora hence there is still a small mix of Lendaswap and Satora terminology