Skip to content

feat(transfer): add LendaSwap/Satora provider - BTC Lightning → USDT0 on Rootstock#688

Open
bonomat wants to merge 8 commits intolayerztec:masterfrom
lendasat:feat/lightning-to-usdt0-rsk
Open

feat(transfer): add LendaSwap/Satora provider - BTC Lightning → USDT0 on Rootstock#688
bonomat wants to merge 8 commits intolayerztec:masterfrom
lendasat:feat/lightning-to-usdt0-rsk

Conversation

@bonomat
Copy link
Copy Markdown

@bonomat bonomat commented Apr 15, 2026

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.

Disclaimer: we are about to rebrand from Lendaswap to Satora hence there is still a small mix of Lendaswap and Satora terminology

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment thread mobile/app/transfer/confirm.tsx Outdated
if (!execution.depositAddress) {
throw new Error('Satora did not return a BOLT11 invoice');
}
await lnWallet.payLightningInvoice(execution.depositAddress);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good catch. failed payment is not handled

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed in e668ed3

Comment thread mobile/app/transfer/confirm.tsx Outdated
Comment on lines +227 to +229
await lnWallet.payLightningInvoice(execution.depositAddress);

await transferService.commitTransfer(execution);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed in e668ed3

@r1n04h r1n04h requested review from evalthis and r1n04h April 15, 2026 13:01
Copy link
Copy Markdown

@JohnnySilverhandBot JohnnySilverhandBot left a comment

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed in a062cb2

Comment thread mobile/app/transfer/confirm.tsx Outdated
if (!execution.depositAddress) {
throw new Error('Satora did not return a BOLT11 invoice');
}
await lnWallet.payLightningInvoice(execution.depositAddress);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed in e668ed3

Copy link
Copy Markdown
Contributor

@r1n04h r1n04h left a comment

Choose a reason for hiding this comment

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

good PR. almost mergeable.

@evalthis your thoughts?

Comment thread mobile/package.json Outdated
"@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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

theres already more fresh 0.2.23

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done in caae60d

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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmm, duplicating whats in shared/models/token-list.ts already. thats fine, the existing data structure is not very friendly to code-reuse

Comment on lines +14 to +18
* 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.
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, that's correct. Swaps should settle quickly so the risk window is small.

Comment thread mobile/app/transfer/confirm.tsx Outdated
found.push(network);
}
} catch {
// wallet not initialized for this account — skip
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

iirc this should never happen. need to catch it and give to error handler globalThis.handleError?.(error, 'blah');

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed in: be1bb2b

}

getTrackingUrl(_execution: TransferExecution): string | undefined {
return undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmm, is there no way to track progress on external website (satora portal)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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(...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, I believe it was right. I've fixed it here: bfbf15b

Comment thread mobile/app/transfer/confirm.tsx Outdated
if (!execution.depositAddress) {
throw new Error('Satora did not return a BOLT11 invoice');
}
await lnWallet.payLightningInvoice(execution.depositAddress);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good catch. failed payment is not handled

isConfirmingRef.current = false;
setIsConfirming(false);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i wonder if its possible to hide handleSatoraConfirm() logic inside transfer-service-satora to make UI code more generic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good idea. Done in : bfbf15b

constructor(private readonly storage: IStorage) {}

private async load(): Promise<PersistedWallet> {
const raw = await this.storage.getItem(STORAGE_KEY_SATORA_WALLET);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

@evalthis
Copy link
Copy Markdown
Contributor

If I'm trying to change 10 BTC error message does not look nice

Simulator Screenshot - iPhone 17 - 2026-04-17 at 15 55 41

@evalthis
Copy link
Copy Markdown
Contributor

Definetly worth derive seed from the master

@r1n04h
Copy link
Copy Markdown
Contributor

r1n04h commented Apr 17, 2026

@evalthis derive from master - what your recommendation would be? bring back bip85 or just hash(existing seed)->turn into seed ?

bonomat added 7 commits April 20, 2026 11:14
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.
@JohnnySilverhandBot
Copy link
Copy Markdown

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.

ios: https://appetize.io/app/ublyelfeg6dj5ghmx4y3a6gkhy

@JohnnySilverhandBot
Copy link
Copy Markdown

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.

android: https://appetize.io/app/lz2gvdvm2f4bke7e4tukued3ta

@r1n04h
Copy link
Copy Markdown
Contributor

r1n04h commented Apr 20, 2026

conflicts

@evalthis
Copy link
Copy Markdown
Contributor

@evalthis derive from master - what your recommendation would be? bring back bip85 or just hash(existing seed)->turn into seed ?

I think bip85, something like m/83696968'/39'/0'/12'/0'

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.

4 participants