Skip to content

feat(realunit): add gasless wallet-to-wallet transfer with dedicated W2W gas wallet#3820

Open
TaprootFreak wants to merge 4 commits into
developfrom
feat/realunit-transfer
Open

feat(realunit): add gasless wallet-to-wallet transfer with dedicated W2W gas wallet#3820
TaprootFreak wants to merge 4 commits into
developfrom
feat/realunit-transfer

Conversation

@TaprootFreak
Copy link
Copy Markdown
Collaborator

@TaprootFreak TaprootFreak commented Jun 4, 2026

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 — body RealUnitTransferDto { 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 integer amount >= 1. Preflights the W2W gas wallet balance. Persists the transfer intent and returns RealUnitTransferPaymentInfoDto { id, uid, toAddress, amount, tokenAddress, chainId, eip7702 { ...delegationData, tokenAddress, amountWei, recipient } }.
  • PUT /v1/realunit/transfer/:id/confirm — body RealUnitTransferConfirmDto (= 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)

Env var Purpose
REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY Signing/relay key (split <br>→newline handling like other keys)
REALUNIT_W2W_GAS_WALLET_ADDRESS Read-only address for balance monitoring (key never needed to monitor)
REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD ETH low-balance threshold (default 0.05)

Operator provisions the wallet: generate key, store in Vault, fund with ETH, set the env vars. A missing key/address throws ServiceUnavailableException when a transfer is attempted.

Security decision — persisted intent (NOT bound-in-signature)

The user's EIP-7702 delegation is a blanket delegation (ROOT_AUTHORITY, empty caveats, only delegate/delegator/salt signed — 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 (RealUnitTransferRequest entity) and relayed verbatim at confirm — recipient/amount are never taken from untrusted client input at confirm. Confirm enforces ownership and a defense-in-depth delegator == request owner check.

How the dedicated relayer key is injected

Eip7702DelegationService.transferTokenWithUserDelegation(...) gained an optional relayerPrivateKeyOverride?: Hex, threaded into _transferTokenWithUserDelegationInternal, where the relayer key resolves to relayerPrivateKeyOverride ?? getRelayerPrivateKey(blockchain). Default is unchanged, so existing callers behave identically. The Sell/OTC path (_executeBrokerBotSellInternal) is untouched and still calls getRelayerPrivateKey(blockchain) unconditionally. transferTokenWithUserDelegation / getRelayerPrivateKey have no other external callers (verified by grep).

Entity + migration

RealUnitTransferRequest { id, uid (unique), user, toAddress, amount, status, txHash, created, updated } + migration AddRealUnitTransferRequest. (Not shoehorned into TransactionRequest — that entity's routeId NOT-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 @DfxCron and raises the standard NotificationService.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, not getRelayerPrivateKey), 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 in realunit.service.ts is 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.tsblockchain.realunit.w2wGas*). This wallet is separate from the per-chain Sell/OTC relayer key.

  1. Generate a dedicated EVM keypair for W2W gas only (e.g. cast wallet new). Do not reuse the Sell/OTC relayer key.
  2. Store the private key in the deployment secret store — never commit it, never put it in the repo.
  3. Fund the wallet address with ETH on the target chain; it pays gas for user-initiated REALU→REALU W2W transfers.
  4. Set the three env vars in both the DEV and PRD deployment environments:
    • 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 (default 0.05).
  5. After deploy, RealUnitW2wGasObserver checks the balance every 10 min and raises the standard monitoring alert below the threshold — top up before transfers start failing with ServiceUnavailable (gas wallet empty).

…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
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

⚠️ Unverified Commits (2)

The following commits are not signed/verified:

  • 9d378b3 feat(realunit): add gasless wallet-to-wallet transfer with dedicated W2W gas wallet (TaprootFreak)
  • ab4e782 fix(realunit): address review on W2W transfer (deterministic migration names, typed caveats, KYC context) (TaprootFreak)
How to sign commits
# SSH signing (recommended)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

# Re-sign last commit
git commit --amend -S --no-edit
git push --force-with-lease

…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)
@TaprootFreak
Copy link
Copy Markdown
Collaborator Author

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.

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.

1 participant