feat(realunit): add gasless wallet-to-wallet transfer with dedicated W2W gas wallet#3820
feat(realunit): add gasless wallet-to-wallet transfer with dedicated W2W gas wallet#3820TaprootFreak wants to merge 4 commits into
Conversation
…W2W gas wallet Add user-initiated RealUnit (REALU) wallet-to-wallet transfer reusing the existing gasless EIP-7702 relay mechanism, but paying gas from a dedicated, separate W2W gas-funding wallet (never the Sell/OTC relayer). - Config: REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY / _ADDRESS / _LOW_BALANCE_THRESHOLD - Thread an optional relayerPrivateKeyOverride into transferTokenWithUserDelegation (defaults to getRelayerPrivateKey, so Sell/OTC paths are unchanged) - Persist transfer intent (RealUnitTransferRequest entity + migration): the blanket EIP-7702 delegation does not bind recipient/amount, so they are stored at prepare and relayed verbatim at confirm (never from untrusted client input) - Endpoints PUT /v1/realunit/transfer and /transfer/:id/confirm (JWT + USER + active, KYC30 + registration gating, limit-exempt on-chain self-custody movement) - Balance-monitoring observer with standard low-balance mail alert - Jest specs + CONTRIBUTING route-taxonomy rows
|
…n names, typed caveats, KYC context) - Use TypeORM deterministic sha1 constraint names in the transfer-request migration (PK/UQ/FK 27, IDX 26) instead of human-readable ones; update up + down - Replace the DTO message.caveats `any[]` with a typed Eip712CaveatDto (enforcer + terms) - Add KycContext.REALUNIT_TRANSFER and use it for the transfer registration/KYC exceptions instead of reusing REALUNIT_SELL - Drop the unused getW2wGasWalletBalance service method (observer reads the balance itself) - Stop loading user.userData in confirmTransfer (user is eager, userData unused) - Fix import ordering in the service spec (realunit-dev before realunit)
Add diff-coverage for every changed executable line and branch in the W2W transfer flow: - prepareTransfer/confirmTransfer: REALU asset-not-found, W2W gas wallet key/address unset (ServiceUnavailable), bare-key 0x normalization - W2W gas observer: address-unset early return and mainnet (Ethereum) client branch - eip7702 transferTokenWithUserDelegation: unsupported-chain throw, relayer-override path (W2W key used, not getRelayerPrivateKey) and default fallback - contextRequiredSteps: REALUNIT_TRANSFER returns no extra steps - thin controller delegations for the two transfer endpoints
…llet (fixes on-chain InvalidDelegate revert)
|
Validated end-to-end on Sepolia testnet (real on-chain, no mocks): the as-shipped W2W transfer initially reverted with InvalidDelegate() (0xb5863604) — tx 0x0c8bb264c562acf8b8eabfaacc90af55cf46838e15b56ee64e50d669ce1a3403 — because the prepared delegation embedded the sell relayer as delegate while the W2W gas wallet redeemed. After the fix (delegate = W2W gas wallet), the as-shipped flow succeeds: tx 0x25eeca5dc23a43d5ab4efa60b42b16172a13f55b9b620d2ce9c3e43a12742391 (type eip7702, recipient +3 REALU, gas paid entirely by the dedicated W2W gas-funding wallet, user EOA paid 0 gas). The dedicated-wallet gas model works as designed; operator provisioning of the production wallet (REALUNIT_W2W_GAS_WALLET_*) remains as documented. |
Summary
Implements user-initiated RealUnit (REALU) wallet-to-wallet (W2W) transfer (Baustein 3). DFX pays gas via the existing gasless EIP-7702 relay, but from a dedicated, separate W2W gas-funding wallet — never the Sell/OTC relayer.
Closes part of RealUnitCH/app#684 (umbrella #666).
Endpoints
PUT /v1/realunit/transfer— bodyRealUnitTransferDto { toAddress: string, amount: number }. Gates on registration + KYC Level 30. Validates recipient (ethers.utils.isAddress, checksum-normalized; rejects sender==recipient and the REALU/ZCHF contract addresses) and integeramount >= 1. Preflights the W2W gas wallet balance. Persists the transfer intent and returnsRealUnitTransferPaymentInfoDto { id, uid, toAddress, amount, tokenAddress, chainId, eip7702 { ...delegationData, tokenAddress, amountWei, recipient } }.PUT /v1/realunit/transfer/:id/confirm— bodyRealUnitTransferConfirmDto(=Eip7702ConfirmDto { delegation, authorization }). Loads the stored request (ownership check), relays the stored recipient+amount via the dedicated W2W key, returns{ txHash }.Both guarded by
JWT + RoleGuard(USER) + UserActiveGuard(same as sell).Dedicated W2W gas wallet — operator config (Vault-backed in prod)
REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY<br>→newline handling like other keys)REALUNIT_W2W_GAS_WALLET_ADDRESSREALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD0.05)Operator provisions the wallet: generate key, store in Vault, fund with ETH, set the env vars. A missing key/address throws
ServiceUnavailableExceptionwhen a transfer is attempted.Security decision — persisted intent (NOT bound-in-signature)
The user's EIP-7702 delegation is a blanket delegation (
ROOT_AUTHORITY, emptycaveats, onlydelegate/delegator/saltsigned — see_prepareDelegationDataInternal). It does not cryptographically bind the recipient or amount; the backend supplies the ERC20 transfer call at execute time. Therefore the transfer intent (recipient + amount) is persisted server-side at prepare time (RealUnitTransferRequestentity) and relayed verbatim at confirm — recipient/amount are never taken from untrusted client input at confirm. Confirm enforces ownership and a defense-in-depthdelegator == request ownercheck.How the dedicated relayer key is injected
Eip7702DelegationService.transferTokenWithUserDelegation(...)gained an optionalrelayerPrivateKeyOverride?: Hex, threaded into_transferTokenWithUserDelegationInternal, where the relayer key resolves torelayerPrivateKeyOverride ?? getRelayerPrivateKey(blockchain). Default is unchanged, so existing callers behave identically. The Sell/OTC path (_executeBrokerBotSellInternal) is untouched and still callsgetRelayerPrivateKey(blockchain)unconditionally.transferTokenWithUserDelegation/getRelayerPrivateKeyhave no other external callers (verified by grep).Entity + migration
RealUnitTransferRequest { id, uid (unique), user, toAddress, amount, status, txHash, created, updated }+ migrationAddRealUnitTransferRequest. (Not shoehorned intoTransactionRequest— that entity'srouteIdNOT-NULL doesn't fit a routeless transfer.)Balance monitoring + alert
RealUnitW2wGasObserver(mirrors the existing balance observers) reads the W2W gas wallet ETH balance every 10 min via@DfxCronand raises the standardNotificationService.sendMail({ type: MailType.ERROR_MONITORING, context: MailContext.MONITORING })alert when below the threshold.Limits / compliance
The W2W transfer is a pure on-chain REALU→REALU self-custody movement → limit-exempt by design (consistent with the swap decision in #3819; trading limits are enforced at the fiat boundary). Gated only on registration + KYC30. No misleading limit check added.
Tests
Jest specs cover: prepare happy path (delegation returned + request persisted with correct to/amount), registration/KYC30 gating, invalid/self/contract recipient + non-integer amount, gas-wallet-empty →
ServiceUnavailable, confirm relays the stored recipient/amount via the dedicated W2W key (asserts the override is passed, notgetRelayerPrivateKey), confirm ownership + delegator-mismatch checks, and the balance-observer low-balance alert path.Local checks (all green)
npm run format/format:check,npm run lint,npm run type-check,npm test(1013 passed),npm run build— all pass.Note on #3819 overlap
A separate OCP branch (
feat/realunit-ocp-pay, #3819) is open but unmerged. This branch is independent; W2W additions live in a clearly delimited// --- W2W TRANSFER --- //section. A trivial conflict with #3819 inrealunit.service.tsis expected — merge in either order.Operator setup — W2W gas-funding wallet (required for runtime)
The W2W transfer endpoints are mergeable as-is, but the flow only works at runtime once an operator provisions the dedicated gas-funding wallet and sets the three env vars below (
src/config/config.ts→blockchain.realunit.w2wGas*). This wallet is separate from the per-chain Sell/OTC relayer key.cast wallet new). Do not reuse the Sell/OTC relayer key.REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY— the funding private key (pays gas). For multi-line key formats, encode newlines as<br>.REALUNIT_W2W_GAS_WALLET_ADDRESS— the wallet address; used read-only by the balance observer (the private key is never needed for monitoring — least privilege).REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD— ETH low-balance alert threshold (default0.05).RealUnitW2wGasObserverchecks the balance every 10 min and raises the standard monitoring alert below the threshold — top up before transfers start failing withServiceUnavailable(gas wallet empty).