Skip to content

Withdraw flow updates - #1041

Merged
nkuba merged 12 commits into
mainfrom
withdraw-flow-updates
Oct 6, 2026
Merged

nkuba merged 12 commits into
mainfrom
withdraw-flow-updates

Conversation

@r-czajkowski

@r-czajkowski r-czajkowski commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Replaces the queued withdrawal with two synchronous redemption paths and enables withdrawals.

  • To BTC - redeem directly to a Bitcoin address, no queue.
  • To Ethereum - redeem as tBTC to an Ethereum address in the same transaction.
  • Withdraw form: destination choice with address validation, per-destination fees and duration estimates, amount locked to the full balance.

NOTE: The new contract is required. We add the new contract in a follow-up PR.

obraz obraz

@netlify

netlify Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for acre-dapp-testnet ready!

Name Link
🔨 Latest commit b7f8a9d
🔍 Latest deploy log https://app.netlify.com/projects/acre-dapp-testnet/deploys/6abe55a9e72bd400081ac5e2
😎 Deploy Preview https://deploy-preview-1041--acre-dapp-testnet.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for acre-dapp-v1 ready!

Name Link
🔨 Latest commit b7f8a9d
🔍 Latest deploy log https://app.netlify.com/projects/acre-dapp-v1/deploys/6abe55a90ad487000872de84
😎 Deploy Preview https://deploy-preview-1041--acre-dapp-v1.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for acre-dapp ready!

Name Link
🔨 Latest commit b7f8a9d
🔍 Latest deploy log https://app.netlify.com/projects/acre-dapp/deploys/6abe55a96e176e00083ad736
😎 Deploy Preview https://deploy-preview-1041--acre-dapp.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@r-czajkowski
r-czajkowski force-pushed the withdraw-flow-updates branch 2 times, most recently from bb6cbe7 to 283e87b Compare August 28, 2026 14:01
Withdrawing to an Ethereum address needs a direct
`redeem(shares, receiver, owner)` call, which the contract handle could
not express: it only knew how to encode `approveAndCall`, the two-step
idiom the Bitcoin path needs because `BitcoinRedeemer` is a third-party
contract that must hold an allowance over the user's shares and be told
to act in one Safe transaction.

Redeeming against our own balance needs neither half of that. OrangeKit
executes the call from the user's Safe, so `msg.sender` is the Safe, and
the same Safe is passed as `owner`. OpenZeppelin's `_withdraw` only
calls `_spendAllowance` when `caller != owner`, so that branch is never
taken and no approval is required.

Encoding only - nothing calls this yet.
`initializeWithdrawal` assumed the queued redemption design: it encoded
`approveAndCall` to `BitcoinRedeemer`, then read a `redemptionRequestId`
back out of the transaction receipt so the caller could track a request
that settled later. Both halves are now wrong. Redemption is synchronous
and complete once the transaction is mined, so there is no request to
track, and there are two destinations rather than one.

It is replaced by two explicitly named methods instead of one method
with a destination flag, so neither path can be reached by accident and
each returns only the identifier it actually has:

- `initializeTbtcWithdrawal(btcAmount, receiverEvmAddress)` redeems
  straight to an Ethereum address and returns just the transaction
  hash. The receiver MUST be an address the user can move funds from -
  the account's own Safe holds no ETH and cannot relay the tBTC back
  out.
- `initializeBitcoinWithdrawal(btcAmount)` routes shares through
  `BitcoinRedeemer` to the tBTC Bridge and returns the redemption key,
  which is the correct identifier when there is no request id and
  matches how the subgraph keys redemptions.

Wallet selection on the Bitcoin path is now sized off
`previewRedeem(shares)`, not the gross amount: `redeem` returns assets
net of the exit fee, and only that net amount reaches the Bridge, so the
gross figure could select a wallet that cannot cover the redemption.

`Tbtc.buildRedemptionData` is replaced by `Tbtc.initiateRedemption`,
which delegates the encoding to the tBTC SDK's
`requestRedemptionWithProxy`. The hand-rolled encoder omitted the
redeemer-output-script length prefix and broke real mainnet withdrawals;
it also passed a zero wallet public key and an empty main UTXO, which
were harmless only while the redeemer ignored them. The live wallet and
its main UTXO are now resolved client-side, because the payload really
does reach `Bridge.requestRedemption`.

The three step callbacks move from `modules/account.ts` to a new
`lib/utils/callbacks.ts` in the same change rather than a separate one:
`lib/redeemer-proxy.ts` needs them, importing from `modules/` into
`lib/` would create an import cycle, and `src/index.ts` star-exports
both `./lib/utils` and `./modules/account`, so a commit where both
declare these names does not compile.
`findRedemptionRequestIdFromTransaction` existed to recover a queued
redemption's request id from the `RedemptionRequested` event in the
transaction receipt. Synchronous redemption emits no such request and
its only caller is gone, so the method, the `#runner` field it needed to
reach the provider, and its tests all go with it.

Also drops the `// TODO: set the new mainnet address` above the mainnet
artifact, which no longer describes reality, and the stale mock entries
on `MockAcreContracts` that only compiled because the
`as BitcoinRedeemer` assertion suppresses excess-property checks.

Documents what is left: this wrapper needs the deployed address and
nothing more. The redemption never calls the redeemer directly - the
Safe calls `acreBTC.approveAndCall` and the redeemer picks it up in
`receiveApproval` - so the artifact is never used to encode calldata and
an ABI change on the redeemer does not ripple into the SDK.
`getEstimatedDuration` quoted 72 hours for a `requested` withdrawal and
6 hours only once it was `pending`. That split existed because a
withdrawal waited in the Midas queue for the next NAV update before
redemption could start, and the longer estimate covered that wait.

Funds now sit on the acreBTC contract, so a request goes straight into
the tBTC redemption process - roughly 5 to 7 hours, averaged to 6. The
`status` parameter has no remaining effect and is dropped, along with
every call site that passed it.

The user-facing copy described the same queue and is updated with it:
the 14-day withdrawal tooltip, the success-modal wait time, and the
pending-withdrawal banner, which also named the NAV update directly.
`BitcoinRedeemerV2` routes through the Midas `WithdrawalQueue`, which stops
working once `MidasAllocator.emergencyWithdraw()` moves the shares out and no
configuration revives it. `BitcoinRedeemerV3` redeems synchronously, so the
Bitcoin path keeps working with the queue unset.

Only the deployed address changes here. The SDK never encodes redeemer
calldata - the user's Safe calls `acreBTC.approveAndCall(bitcoinRedeemer,
shares, redemptionData)` and the redeemer picks it up in `receiveApproval` -
so the ABI change between V2 and V3 does not ripple into the SDK.

Depends on `withdraw-funds-from-midas-to-acrebtc`, which adds the contract
along with its mainnet and sepolia deployment artifacts. Until that lands the
`sdk` package does not typecheck.
`BitcoinRedeemerV2` routes through the Midas `WithdrawalQueue`, which
stops working once `MidasAllocator.emergencyWithdraw()` moves the Midas
shares out, and no configuration revives it. `BitcoinRedeemerV3` is the
original `BitcoinRedeemer` with `stBTC` swapped for `acreBTC`, so the
redemption is synchronous again and needs no allocator shares.

Deploy a fresh proxy instead of upgrading, so V2 stays in place as a
rollback. `tbtcVault` is set in the initializer, so no follow-up
`updateTbtcVault` call is needed.

@kkosiorowska kkosiorowska left a comment

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.

Quick question about release timing. The commits assume the funds already sit on acreBTC, and redeem pays out only from that balance, so until the Midas exit settles, withdrawals would just revert.

Am I right that we don't need to add anything in the code for this, and we just merge it once the tBTC from Midas has landed on acreBTC and the vault is unpaused? If so, maybe it's worth adding that to the PR description so nobody merges it too early.

Hex.from(transactionHash),
)
return { transactionHash, redemptionRequestId }
return { transactionHash }

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.

Shouldn't we check the receipt status here? Before, findRedemptionRequestIdFromTransaction did it indirectly. Now we only return the hash, and neither OrangeKit nor the relayer sender checks receipt.status. If the tx reverts (e.g. the vault is paused or the main UTXO changes), the dApp still shows success.

Maybe we could add waitForTransaction + throw if status !== 1 in AcreRelayerTransactionSender? Then it covers both paths (here and redeemer-proxy.ts) in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

IMO not a real issue:

  • OrangeKit sendTransaction returns whatever hash the TransactionSender gives it.
  • AcreRelayerTransactionSender send POST request to acre-api. The relayer:
    • calls viem wallet.sendTransaction, which estimates gas first, so a call that would revert (paused vault, stale UTXO) fails before it is broadcast;
    • then calls waitForTransactionReceipt and throws "Transaction … reverted on-chain" if status !== "success".
  • Both errors come back as HTTP 502. AcreRelayerTransactionSender throws on !response.ok, so the dApp goes to onError, not success.

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.

Should the optimistic tBTC withdrawal still be added as "pending"? Since it now settles in a single tx (the subgraph marks it Requested + Finalized at once, so it comes back as completed), it looks like the dashboard would briefly show the "Withdrawal in Progress" banner with "~6 hours" until the next refetch. The banner and EstimatedDuration in the history don't get the destination, so they fall back to the Bitcoin estimate. I haven't verified this in the app though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

throw new Error("Receiver cannot be the zero address")

const data = this.instance.interface.encodeFunctionData("requestRedeem", [
const data = this.instance.interface.encodeFunctionData("redeem", [

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.

Claude flagged this one for me during the review, so I wanted to ask if it's actually a thing.

Withdrawals go through our relayer, and as far as I understand it only sends transactions that are on its allowlist. This PR changes what the withdrawals call:

  • tBTC withdrawal: acreBTC.redeem instead of acreBTC.requestRedeem
  • BTC withdrawal: acreBTC.approveAndCall now approves BitcoinRedeemerV3 instead of BitcoinRedeemerV2

Does the relayer allowlist only check that the call goes to acreBTC, or does it also check the function name and the approved contract? If it's the second, the relayer would reject both withdrawals after release, so we'd need to update the allowlist first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Relayer only checks which contract calls, not the function.

The relayer returns the hash only once the transaction is mined
successfully, and a tBTC redemption settles in that transaction. The
optimistic `pending` activity showed the Bitcoin "~6 hours" banner until
the next refetch.
@r-czajkowski

Copy link
Copy Markdown
Contributor Author

Quick question about release timing. The commits assume the funds already sit on acreBTC, and redeem pays out only from that balance, so until the Midas exit settles, withdrawals would just revert.

Am I right that we don't need to add anything in the code for this, and we just merge it once the tBTC from Midas has landed on acreBTC and the vault is unpaused? If so, maybe it's worth adding that to the PR description so nobody merges it too early.

Everything is ready, funds are in acreBTC contract. We can merge the PR.

@r-czajkowski r-czajkowski self-assigned this Oct 1, 2026
@r-czajkowski r-czajkowski added 🔌 SDK TypeScript SDK Library 🎨 dApp dApp labels Oct 1, 2026
@r-czajkowski
r-czajkowski marked this pull request as ready for review October 1, 2026 13:02

@kkosiorowska kkosiorowska left a comment

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.

LGTM 🚀 I only have read access to the repo, so I can't resolve the threads or merge myself. @r-czajkowski, could you resolve them and merge when ready?

@nkuba
nkuba merged commit f30cc65 into main Oct 6, 2026
34 of 35 checks passed
@nkuba
nkuba deleted the withdraw-flow-updates branch October 6, 2026 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🎨 dApp dApp 🔌 SDK TypeScript SDK Library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants