From 9d378b3a04be8af2204794848775f3529c2a301c Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 4 Jun 2026 10:08:15 +0200
Subject: [PATCH 1/4] feat(realunit): add gasless wallet-to-wallet transfer
with dedicated 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
---
CONTRIBUTING.md | 2 +
...780560119568-AddRealUnitTransferRequest.js | 30 ++
src/config/config.ts | 8 +
.../delegation/eip7702-delegation.service.ts | 13 +-
.../core/monitoring/monitoring.module.ts | 6 +
.../realunit-w2w-gas.observer.spec.ts | 102 +++++++
.../observers/realunit-w2w-gas.observer.ts | 92 ++++++
.../__tests__/realunit.service.spec.ts | 273 +++++++++++++++++-
.../controllers/realunit.controller.ts | 48 +++
.../realunit/dto/realunit-transfer.dto.ts | 100 +++++++
.../realunit-transfer-request.entity.ts | 54 ++++
.../supporting/realunit/realunit.module.ts | 6 +-
.../supporting/realunit/realunit.service.ts | 172 +++++++++++
.../realunit-transfer-request.repository.ts | 13 +
14 files changed, 911 insertions(+), 8 deletions(-)
create mode 100644 migration/1780560119568-AddRealUnitTransferRequest.js
create mode 100644 src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
create mode 100644 src/subdomains/core/monitoring/observers/realunit-w2w-gas.observer.ts
create mode 100644 src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
create mode 100644 src/subdomains/supporting/realunit/entities/realunit-transfer-request.entity.ts
create mode 100644 src/subdomains/supporting/realunit/repositories/realunit-transfer-request.repository.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cbea27872a..07acc258a6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -958,6 +958,8 @@ The RealUnit purchase and sale flows historically lived under `/v1/realunit/brok
| `PUT /v1/realunit/sell/:id/unsigned-transactions` | Reads the on-chain sell price and builds the EIP-7702 batch the user has to sign | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` |
| `PUT /v1/realunit/sell/:id/confirm` | Verifies the user-signed batch against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` |
| `PUT /v1/realunit/sell/:id/broadcast` | Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no `readContract` |
+| `PUT /v1/realunit/transfer` | Persists a wallet-to-wallet (W2W) transfer intent and returns the EIP-7702 delegation data to sign. Limit-exempt (on-chain REALU→REALU self-custody movement). | No — prepares the gasless transfer |
+| `PUT /v1/realunit/transfer/:id/confirm` | Relays the user-signed EIP-7702 delegation for the stored transfer request; DFX pays gas from the dedicated W2W gas wallet (`REALUNIT_W2W_GAS_WALLET_*`), never the Sell/OTC relayer | No `readContract` — relays the user-authorized ERC20 transfer |
Operational consequences:
diff --git a/migration/1780560119568-AddRealUnitTransferRequest.js b/migration/1780560119568-AddRealUnitTransferRequest.js
new file mode 100644
index 0000000000..4cf2820654
--- /dev/null
+++ b/migration/1780560119568-AddRealUnitTransferRequest.js
@@ -0,0 +1,30 @@
+/**
+ * @typedef {import('typeorm').MigrationInterface} MigrationInterface
+ * @typedef {import('typeorm').QueryRunner} QueryRunner
+ */
+
+/**
+ * @class
+ * @implements {MigrationInterface}
+ */
+module.exports = class AddRealUnitTransferRequest1780560119568 {
+ name = 'AddRealUnitTransferRequest1780560119568'
+
+ /**
+ * @param {QueryRunner} queryRunner
+ */
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "real_unit_transfer_request" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "uid" character varying(256) NOT NULL, "toAddress" character varying(256) NOT NULL, "amount" double precision NOT NULL, "status" character varying(256) NOT NULL DEFAULT 'Created', "txHash" character varying(256), "userId" integer NOT NULL, CONSTRAINT "UQ_real_unit_transfer_request_uid" UNIQUE ("uid"), CONSTRAINT "PK_real_unit_transfer_request" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_real_unit_transfer_request_userId" ON "real_unit_transfer_request" ("userId") `);
+ await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" ADD CONSTRAINT "FK_real_unit_transfer_request_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+ }
+
+ /**
+ * @param {QueryRunner} queryRunner
+ */
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" DROP CONSTRAINT "FK_real_unit_transfer_request_userId"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_real_unit_transfer_request_userId"`);
+ await queryRunner.query(`DROP TABLE "real_unit_transfer_request"`);
+ }
+}
diff --git a/src/config/config.ts b/src/config/config.ts
index c17539cf91..80dd785e72 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -94,6 +94,7 @@ export class Configuration {
paymentLinkUidPrefix: 'pl',
paymentLinkPaymentUidPrefix: 'plp',
paymentQuoteUidPrefix: 'plq',
+ realUnitTransferUidPrefix: 'RT',
};
moderators = {
@@ -1060,6 +1061,13 @@ export class Configuration {
brokerbotAddress: [Environment.DEV, Environment.LOC].includes(this.environment)
? '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2'
: '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d',
+ // Dedicated wallet-to-wallet (W2W) transfer gas-funding wallet. Separate from the Sell/OTC
+ // EIP-7702 relayer (per-chain `…WalletPrivateKey`): DFX pays gas for user-initiated REALU
+ // W2W transfers from this wallet only. Operator provisions it (generate key, store in Vault,
+ // fund with ETH) and sets the three env vars below.
+ w2wGasWalletPrivateKey: process.env.REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY?.split('
').join('\n'),
+ w2wGasWalletAddress: process.env.REALUNIT_W2W_GAS_WALLET_ADDRESS,
+ w2wGasLowBalanceThreshold: +(process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD ?? 0.05), // ETH
bank: {
recipient: process.env.REALUNIT_BANK_RECIPIENT ?? 'RealUnit Schweiz AG',
iban: process.env.REALUNIT_BANK_IBAN ?? 'CH22 0830 7000 5609 4630 9',
diff --git a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
index 3edd6feec6..f57766e8ab 100644
--- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
+++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
@@ -258,6 +258,10 @@ export class Eip7702DelegationService {
/**
* Execute token transfer using frontend-signed EIP-7702 delegation
* Used for sell transactions where user has 0 native token
+ *
+ * `relayerPrivateKeyOverride` (optional) pays gas from a caller-supplied wallet instead of the
+ * per-chain Sell/OTC relayer. Defaults to `getRelayerPrivateKey(blockchain)`, so existing callers
+ * are unchanged. Used by the RealUnit W2W transfer to pay gas from the dedicated W2W gas wallet.
*/
async transferTokenWithUserDelegation(
userAddress: string,
@@ -272,8 +276,9 @@ export class Eip7702DelegationService {
signature: string;
},
authorization: Eip7702Authorization,
+ relayerPrivateKeyOverride?: Hex,
): Promise {
- if (!this.isDelegationSupported(token.blockchain)) {
+ if (!this.isDelegationSupported(token.blockchain) && !this.isDelegationSupportedForRealUnit(token.blockchain)) {
throw new Error(`EIP-7702 delegation not supported for ${token.blockchain}`);
}
return this._transferTokenWithUserDelegationInternal(
@@ -283,6 +288,7 @@ export class Eip7702DelegationService {
amount,
signedDelegation,
authorization,
+ relayerPrivateKeyOverride,
);
}
@@ -534,6 +540,7 @@ export class Eip7702DelegationService {
signature: string;
},
authorization: Eip7702Authorization,
+ relayerPrivateKeyOverride?: Hex,
): Promise {
const blockchain = token.blockchain;
@@ -571,8 +578,8 @@ export class Eip7702DelegationService {
// Verify EIP-7702 authorization signature
await this.verifyAuthorizationSignature(authorization, userAddress);
- // Get relayer account
- const relayerPrivateKey = this.getRelayerPrivateKey(blockchain);
+ // Get relayer account (default: per-chain Sell/OTC relayer; override: dedicated gas wallet)
+ const relayerPrivateKey = relayerPrivateKeyOverride ?? this.getRelayerPrivateKey(blockchain);
const relayerAccount = privateKeyToAccount(relayerPrivateKey);
// Create clients
diff --git a/src/subdomains/core/monitoring/monitoring.module.ts b/src/subdomains/core/monitoring/monitoring.module.ts
index a4493c4326..8de29db84d 100644
--- a/src/subdomains/core/monitoring/monitoring.module.ts
+++ b/src/subdomains/core/monitoring/monitoring.module.ts
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BankIntegrationModule } from 'src/integration/bank/bank.module';
import { BitcoinModule } from 'src/integration/blockchain/bitcoin/bitcoin.module';
+import { EthereumModule } from 'src/integration/blockchain/ethereum/ethereum.module';
+import { SepoliaModule } from 'src/integration/blockchain/sepolia/sepolia.module';
import { IntegrationModule } from 'src/integration/integration.module';
import { LetterModule } from 'src/integration/letter/letter.module';
import { LightningModule } from 'src/integration/lightning/lightning.module';
@@ -25,6 +27,7 @@ import { LiquidityObserver } from './observers/liquidity.observer';
import { NodeBalanceObserver } from './observers/node-balance.observer';
import { NodeHealthObserver } from './observers/node-health.observer';
import { PaymentObserver } from './observers/payment.observer';
+import { RealUnitW2wGasObserver } from './observers/realunit-w2w-gas.observer';
import { UserObserver } from './observers/user.observer';
import { SystemStateSnapshot } from './system-state-snapshot.entity';
import { SystemStateSnapshotRepository } from './system-state-snapshot.repository';
@@ -43,6 +46,8 @@ import { SystemStateSnapshotRepository } from './system-state-snapshot.repositor
LightningModule,
FiatPayInModule,
PricingModule,
+ EthereumModule,
+ SepoliaModule,
],
providers: [
SystemStateSnapshotRepository,
@@ -59,6 +64,7 @@ import { SystemStateSnapshotRepository } from './system-state-snapshot.repositor
AmlObserver,
ExchangeObserver,
LiquidityObserver,
+ RealUnitW2wGasObserver,
],
controllers: [MonitoringController, HealthController],
exports: [MonitoringService],
diff --git a/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
new file mode 100644
index 0000000000..05d9413211
--- /dev/null
+++ b/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
@@ -0,0 +1,102 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
+import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service';
+import { MailType } from 'src/subdomains/supporting/notification/enums';
+import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service';
+import { MonitoringService } from '../../monitoring.service';
+import { RealUnitW2wGasObserver } from '../realunit-w2w-gas.observer';
+
+jest.mock('src/config/config', () => {
+ const blockchain = {
+ realunit: {
+ w2wGasWalletAddress: '0xW2wGasWalletAddress',
+ w2wGasLowBalanceThreshold: 0.05,
+ },
+ ethereum: { ethChainId: 1 },
+ sepolia: { sepoliaChainId: 11155111 },
+ arbitrum: { arbitrumChainId: 42161 },
+ optimism: { optimismChainId: 10 },
+ polygon: { polygonChainId: 137 },
+ base: { baseChainId: 8453 },
+ gnosis: { gnosisChainId: 100 },
+ bsc: { bscChainId: 56 },
+ citrea: { citreaChainId: 4114 },
+ citreaTestnet: { citreaTestnetChainId: 5115 },
+ };
+ return {
+ get Config() {
+ return { environment: 'loc', blockchain };
+ },
+ Environment: { LOC: 'loc', DEV: 'dev', PRD: 'prd' },
+ GetConfig: jest.fn(() => ({
+ blockchain,
+ payment: { fee: 0.01, defaultPaymentTimeout: 900 },
+ formats: {
+ address: /.*/,
+ signature: /.*/,
+ key: /.*/,
+ ref: /.*/,
+ bankUsage: /.*/,
+ recommendationCode: /.*/,
+ kycHash: /.*/,
+ phone: /.*/,
+ accountServiceRef: /.*/,
+ number: /.*/,
+ transactionUid: /.*/,
+ },
+ })),
+ };
+});
+
+jest.mock('src/shared/services/dfx-logger', () => ({
+ DfxLogger: jest.fn().mockImplementation(() => ({
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ })),
+}));
+
+describe('RealUnitW2wGasObserver', () => {
+ let observer: RealUnitW2wGasObserver;
+ let sepoliaClient: { getNativeCoinBalanceForAddress: jest.Mock };
+ let notificationService: jest.Mocked;
+
+ beforeEach(async () => {
+ sepoliaClient = { getNativeCoinBalanceForAddress: jest.fn() };
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ RealUnitW2wGasObserver,
+ { provide: MonitoringService, useValue: { register: jest.fn() } },
+ { provide: EthereumService, useValue: { getDefaultClient: jest.fn() } },
+ { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(sepoliaClient) } },
+ { provide: NotificationService, useValue: { sendMail: jest.fn() } },
+ ],
+ }).compile();
+
+ observer = module.get(RealUnitW2wGasObserver);
+ notificationService = module.get(NotificationService);
+ });
+
+ afterEach(() => jest.clearAllMocks());
+
+ it('raises a low-balance alert when balance is below the threshold', async () => {
+ sepoliaClient.getNativeCoinBalanceForAddress.mockResolvedValue(0.001);
+
+ const data = await observer.fetch();
+
+ expect(data.lowBalance).toBe(true);
+ expect(notificationService.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({ type: MailType.ERROR_MONITORING }),
+ );
+ });
+
+ it('does NOT alert when balance is above the threshold', async () => {
+ sepoliaClient.getNativeCoinBalanceForAddress.mockResolvedValue(1);
+
+ const data = await observer.fetch();
+
+ expect(data.lowBalance).toBe(false);
+ expect(notificationService.sendMail).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/subdomains/core/monitoring/observers/realunit-w2w-gas.observer.ts b/src/subdomains/core/monitoring/observers/realunit-w2w-gas.observer.ts
new file mode 100644
index 0000000000..8f38a447ce
--- /dev/null
+++ b/src/subdomains/core/monitoring/observers/realunit-w2w-gas.observer.ts
@@ -0,0 +1,92 @@
+import { Injectable } from '@nestjs/common';
+import { CronExpression } from '@nestjs/schedule';
+import { Config, Environment } from 'src/config/config';
+import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
+import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service';
+import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client';
+import { DfxLogger } from 'src/shared/services/dfx-logger';
+import { Process } from 'src/shared/services/process.service';
+import { DfxCron } from 'src/shared/utils/cron';
+import { MetricObserver } from 'src/subdomains/core/monitoring/metric.observer';
+import { MonitoringService } from 'src/subdomains/core/monitoring/monitoring.service';
+import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums';
+import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service';
+
+// --- W2W TRANSFER --- //
+
+interface RealUnitW2wGasData {
+ address?: string;
+ balance?: number; // ETH
+ threshold: number; // ETH
+ lowBalance: boolean;
+}
+
+/**
+ * Monitors the ETH balance of the dedicated RealUnit wallet-to-wallet (W2W) gas-funding wallet
+ * (read-only via Config.blockchain.realunit.w2wGasWalletAddress — the private key is never needed
+ * here) and raises the standard low-balance monitoring alert when it drops below the configured
+ * threshold so the operator can top it up before user transfers start failing.
+ */
+@Injectable()
+export class RealUnitW2wGasObserver extends MetricObserver {
+ protected readonly logger = new DfxLogger(RealUnitW2wGasObserver);
+
+ constructor(
+ monitoringService: MonitoringService,
+ private readonly ethereumService: EthereumService,
+ private readonly sepoliaService: SepoliaService,
+ private readonly notificationService: NotificationService,
+ ) {
+ super(monitoringService, 'realUnit', 'w2wGasBalance');
+ }
+
+ @DfxCron(CronExpression.EVERY_10_MINUTES, { process: Process.MONITORING, timeout: 1800 })
+ async fetch(): Promise {
+ const data = await this.getData();
+
+ if (data.lowBalance) await this.alertLowBalance(data);
+
+ this.emit(data);
+
+ return data;
+ }
+
+ // --- HELPER METHODS --- //
+
+ private async getData(): Promise {
+ const { w2wGasWalletAddress, w2wGasLowBalanceThreshold } = Config.blockchain.realunit;
+
+ if (!w2wGasWalletAddress) {
+ return { address: undefined, balance: undefined, threshold: w2wGasLowBalanceThreshold, lowBalance: false };
+ }
+
+ const balance = await this.getClient().getNativeCoinBalanceForAddress(w2wGasWalletAddress);
+
+ return {
+ address: w2wGasWalletAddress,
+ balance,
+ threshold: w2wGasLowBalanceThreshold,
+ lowBalance: balance < w2wGasLowBalanceThreshold,
+ };
+ }
+
+ private async alertLowBalance(data: RealUnitW2wGasData): Promise {
+ const message = `RealUnit W2W gas wallet ${data.address} balance ${data.balance} ETH is below threshold ${data.threshold} ETH`;
+ this.logger.error(message);
+
+ await this.notificationService.sendMail({
+ type: MailType.ERROR_MONITORING,
+ context: MailContext.MONITORING,
+ input: {
+ subject: 'RealUnit W2W gas wallet low balance',
+ errors: [message],
+ },
+ });
+ }
+
+ private getClient(): EvmClient {
+ return [Environment.DEV, Environment.LOC].includes(Config.environment)
+ ? this.sepoliaService.getDefaultClient()
+ : this.ethereumService.getDefaultClient();
+ }
+}
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
index ab690a0564..340d5f55bc 100644
--- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
@@ -1,4 +1,4 @@
-import { BadRequestException, ConflictException } from '@nestjs/common';
+import { BadRequestException, ConflictException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto';
@@ -27,12 +27,25 @@ import { TransactionService } from 'src/subdomains/supporting/payment/services/t
import { AssetPricesService } from '../../pricing/services/asset-prices.service';
import { PricingService } from '../../pricing/services/pricing.service';
import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto';
-import { RealUnitDevService } from '../realunit-dev.service';
+import { RealUnitTransferRequestStatus } from '../entities/realunit-transfer-request.entity';
import { RealUnitService } from '../realunit.service';
+import { RealUnitDevService } from '../realunit-dev.service';
+import { RealUnitTransferRequestRepository } from '../repositories/realunit-transfer-request.repository';
jest.mock('src/config/config', () => ({
get Config() {
- return { environment: 'loc' };
+ return {
+ environment: 'loc',
+ prefixes: { realUnitTransferUidPrefix: 'RT' },
+ blockchain: {
+ realunit: {
+ brokerbotAddress: '0xBrokerbotAddress',
+ w2wGasWalletPrivateKey: '0xW2wGasKey',
+ w2wGasWalletAddress: '0xW2wGasWalletAddress',
+ w2wGasLowBalanceThreshold: 0.05,
+ },
+ },
+ };
},
Environment: {
LOC: 'loc',
@@ -102,6 +115,8 @@ describe('RealUnitService', () => {
let sellService: jest.Mocked;
let userService: jest.Mocked;
let kycService: jest.Mocked;
+ let transferRequestRepo: jest.Mocked;
+ let sepoliaClient: { getNativeCoinBalanceForAddress: jest.Mock };
const realuAsset = createCustomAsset({
id: 1,
@@ -122,6 +137,8 @@ describe('RealUnitService', () => {
});
beforeEach(async () => {
+ sepoliaClient = { getNativeCoinBalanceForAddress: jest.fn() };
+
const module: TestingModule = await Test.createTestingModule({
providers: [
RealUnitService,
@@ -168,6 +185,8 @@ describe('RealUnitService', () => {
provide: Eip7702DelegationService,
useValue: {
executeBrokerBotSellForRealUnit: jest.fn(),
+ prepareDelegationDataForRealUnit: jest.fn(),
+ transferTokenWithUserDelegation: jest.fn(),
},
},
{
@@ -184,7 +203,20 @@ describe('RealUnitService', () => {
{ provide: FeeService, useValue: {} },
{ provide: FaucetRequestService, useValue: {} },
{ provide: EthereumService, useValue: {} },
- { provide: SepoliaService, useValue: {} },
+ {
+ provide: SepoliaService,
+ useValue: {
+ getDefaultClient: jest.fn().mockReturnValue(sepoliaClient),
+ },
+ },
+ {
+ provide: RealUnitTransferRequestRepository,
+ useValue: {
+ create: jest.fn((e) => e),
+ save: jest.fn((e) => Promise.resolve({ id: 99, ...e })),
+ findOne: jest.fn(),
+ },
+ },
],
}).compile();
@@ -196,6 +228,7 @@ describe('RealUnitService', () => {
sellService = module.get(SellService);
userService = module.get(UserService);
kycService = module.get(KycService);
+ transferRequestRepo = module.get(RealUnitTransferRequestRepository);
});
afterEach(() => {
@@ -390,6 +423,238 @@ describe('RealUnitService', () => {
});
});
+ describe('W2W transfer', () => {
+ const senderAddress = '0x1111111111111111111111111111111111111111';
+ const recipientAddress = '0x2222222222222222222222222222222222222222';
+ const realuContract = '0x3333333333333333333333333333333333333333';
+ const zchfContract = '0x4444444444444444444444444444444444444444';
+ const w2wTxHash = '0x' + 'b'.repeat(64);
+
+ const transferRealuAsset = createCustomAsset({
+ id: 1,
+ name: 'REALU',
+ blockchain: Blockchain.SEPOLIA,
+ type: AssetType.TOKEN,
+ chainId: realuContract,
+ decimals: 0,
+ });
+
+ const transferZchfAsset = createCustomAsset({
+ id: 2,
+ name: 'ZCHF',
+ blockchain: Blockchain.SEPOLIA,
+ type: AssetType.TOKEN,
+ chainId: zchfContract,
+ decimals: 18,
+ });
+
+ const delegationData = {
+ relayerAddress: '0xRelayer',
+ delegationManagerAddress: '0xManager',
+ delegatorAddress: '0xDelegator',
+ userNonce: 0,
+ domain: { name: 'DelegationManager', version: '1', chainId: 11155111, verifyingContract: '0xManager' },
+ types: { Delegation: [], Caveat: [] },
+ message: { delegate: '0xRelayer', delegator: senderAddress, authority: '0xRoot', caveats: [], salt: 1 },
+ };
+
+ function buildRegisteredUser(kycLevel: number): any {
+ const step = {
+ getResult: () => ({ walletAddress: senderAddress }),
+ isFailed: false,
+ isCanceled: false,
+ isCompleted: true,
+ result: 'non-empty',
+ };
+ return {
+ id: 42,
+ address: senderAddress,
+ userData: {
+ kycLevel,
+ getStepsWith: jest.fn().mockReturnValue([step]),
+ },
+ };
+ }
+
+ function mockTransferAssets(): void {
+ assetService.getAssetByQuery.mockImplementation(async (q: any) =>
+ q.name === 'REALU' ? transferRealuAsset : transferZchfAsset,
+ );
+ }
+
+ describe('prepareTransfer', () => {
+ it('returns delegation data and persists the request with correct to/amount', async () => {
+ mockTransferAssets();
+ sepoliaClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); // funded
+ eip7702DelegationService.prepareDelegationDataForRealUnit.mockResolvedValue(delegationData as any);
+
+ const user = buildRegisteredUser(30);
+ const result = await service.prepareTransfer(user, { toAddress: recipientAddress, amount: 5 });
+
+ expect(eip7702DelegationService.prepareDelegationDataForRealUnit).toHaveBeenCalledWith(
+ senderAddress,
+ Blockchain.SEPOLIA,
+ );
+ expect(transferRequestRepo.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toAddress: recipientAddress,
+ amount: 5,
+ status: RealUnitTransferRequestStatus.CREATED,
+ }),
+ );
+ expect(result.toAddress).toBe(recipientAddress);
+ expect(result.amount).toBe(5);
+ expect(result.eip7702.recipient).toBe(recipientAddress);
+ expect(result.eip7702.amountWei).toBe('5');
+ });
+
+ it('throws when registration is missing', async () => {
+ const user: any = {
+ id: 42,
+ address: senderAddress,
+ userData: { kycLevel: 30, getStepsWith: jest.fn().mockReturnValue([]) },
+ };
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toBeDefined();
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
+
+ it('throws when KYC level is below 30', async () => {
+ const user = buildRegisteredUser(20);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toBeDefined();
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
+
+ it('rejects an invalid recipient address', async () => {
+ mockTransferAssets();
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: 'not-an-address', amount: 1 })).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('rejects sender == recipient', async () => {
+ mockTransferAssets();
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: senderAddress, amount: 1 })).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('rejects the REALU token contract as recipient', async () => {
+ mockTransferAssets();
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: realuContract, amount: 1 })).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('rejects a non-integer amount', async () => {
+ mockTransferAssets();
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1.5 })).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('throws ServiceUnavailable when the W2W gas wallet balance is below threshold', async () => {
+ mockTransferAssets();
+ sepoliaClient.getNativeCoinBalanceForAddress.mockResolvedValue(0.001); // below 0.05 threshold
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toThrow(
+ ServiceUnavailableException,
+ );
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('confirmTransfer', () => {
+ const confirmDto: any = {
+ delegation: {
+ delegator: senderAddress,
+ delegate: '0xRelayer',
+ authority: '0xRoot',
+ salt: '1',
+ signature: '0xSig',
+ },
+ authorization: { chainId: 11155111, address: '0xDelegator', nonce: 0, r: '0xR', s: '0xS', yParity: 0 },
+ };
+
+ function buildStoredRequest(overrides: any = {}): any {
+ return {
+ id: 99,
+ uid: 'RTabc',
+ toAddress: recipientAddress,
+ amount: 5,
+ status: RealUnitTransferRequestStatus.CREATED,
+ isComplete: false,
+ user: { id: 42, address: senderAddress, userData: {} },
+ complete: jest.fn(function (this: any, txHash: string) {
+ this.status = RealUnitTransferRequestStatus.COMPLETED;
+ this.txHash = txHash;
+ return this;
+ }),
+ ...overrides,
+ };
+ }
+
+ it('relays the stored recipient/amount via the dedicated W2W key (NOT getRelayerPrivateKey)', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
+ assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
+ eip7702DelegationService.transferTokenWithUserDelegation.mockResolvedValue(w2wTxHash);
+
+ const result = await service.confirmTransfer(42, 99, confirmDto);
+
+ expect(result.txHash).toBe(w2wTxHash);
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).toHaveBeenCalledWith(
+ senderAddress,
+ transferRealuAsset,
+ recipientAddress, // STORED recipient, not from client
+ 5, // STORED amount, not from client
+ confirmDto.delegation,
+ confirmDto.authorization,
+ '0xW2wGasKey', // dedicated W2W relayer key override
+ );
+ });
+
+ it('throws NotFound when the request belongs to another user', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest({ user: { id: 7, address: senderAddress } }));
+
+ await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(NotFoundException);
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled();
+ });
+
+ it('throws NotFound when the request does not exist', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(null as any);
+
+ await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(NotFoundException);
+ });
+
+ it('throws Conflict when the request is already completed', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest({ isComplete: true }));
+ assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
+
+ await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(ConflictException);
+ });
+
+ it('throws BadRequest when the delegator does not match the request owner', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
+ assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
+
+ const wrongDto = { ...confirmDto, delegation: { ...confirmDto.delegation, delegator: '0xWrong' } };
+
+ await expect(service.confirmTransfer(42, 99, wrongDto)).rejects.toThrow(BadRequestException);
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled();
+ });
+ });
+ });
+
describe('completeRegistrationForWalletAddress (idempotency)', () => {
const walletAddress = '0x1111111111111111111111111111111111111111';
const userDataId = 42;
diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts
index ea66d6c19b..c6e318a53f 100644
--- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts
+++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts
@@ -6,6 +6,7 @@ import {
ApiBearerAuth,
ApiConflictResponse,
ApiExcludeEndpoint,
+ ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
@@ -60,6 +61,11 @@ import {
RealUnitSellDto,
RealUnitSellPaymentInfoDto,
} from '../dto/realunit-sell.dto';
+import {
+ RealUnitTransferConfirmDto,
+ RealUnitTransferDto,
+ RealUnitTransferPaymentInfoDto,
+} from '../dto/realunit-transfer.dto';
import {
AccountHistoryDto,
AccountHistoryQueryDto,
@@ -604,6 +610,48 @@ export class RealUnitController {
return this.realunitService.broadcastSellTransaction(jwt.user, +id, dto);
}
+ // --- W2W Transfer Endpoints --- //
+
+ @Put('transfer')
+ @ApiBearerAuth()
+ @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard())
+ @ApiOperation({
+ summary: 'Prepare a gasless RealUnit wallet-to-wallet transfer',
+ description:
+ 'Persists the transfer intent and returns the EIP-7702 delegation data the app must sign for a gasless REALU transfer. DFX pays gas from a dedicated W2W gas wallet. Requires KYC Level 30 and RealUnit registration.',
+ })
+ @ApiOkResponse({ type: RealUnitTransferPaymentInfoDto })
+ @ApiBadRequestResponse({
+ description: 'KYC Level 30 required, registration missing, or invalid recipient/amount',
+ })
+ async prepareTransfer(
+ @GetJwt() jwt: JwtPayload,
+ @Body() dto: RealUnitTransferDto,
+ ): Promise {
+ const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } });
+ return this.realunitService.prepareTransfer(user, dto);
+ }
+
+ @Put('transfer/:id/confirm')
+ @ApiBearerAuth()
+ @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard())
+ @ApiOperation({
+ summary: 'Confirm a RealUnit wallet-to-wallet transfer',
+ description:
+ 'Relays the user-signed EIP-7702 delegation for the stored transfer request. DFX pays gas from the dedicated W2W gas wallet. Returns the transaction hash.',
+ })
+ @ApiParam({ name: 'id', description: 'Transfer request ID' })
+ @ApiOkResponse({ description: 'Transfer confirmed', schema: { properties: { txHash: { type: 'string' } } } })
+ @ApiBadRequestResponse({ description: 'Invalid delegation or authorization' })
+ @ApiNotFoundResponse({ description: 'Transfer request not found' })
+ async confirmTransfer(
+ @GetJwt() jwt: JwtPayload,
+ @Param('id') id: string,
+ @Body() dto: RealUnitTransferConfirmDto,
+ ): Promise<{ txHash: string }> {
+ return this.realunitService.confirmTransfer(jwt.user, +id, dto);
+ }
+
// --- Registration Info Endpoint ---
@Get('registration')
diff --git a/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
new file mode 100644
index 0000000000..677ef8002a
--- /dev/null
+++ b/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
@@ -0,0 +1,100 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsNotEmpty, IsNumber, IsString, Matches, Min } from 'class-validator';
+import { GetConfig } from 'src/config/config';
+import { Eip7702ConfirmDto } from 'src/subdomains/core/sell-crypto/route/dto/eip7702-delegation.dto';
+
+// --- W2W TRANSFER --- //
+
+// --- Request DTOs ---
+
+export class RealUnitTransferDto {
+ @ApiProperty({ description: 'Recipient wallet address (EVM)' })
+ @IsNotEmpty()
+ @IsString()
+ @Matches(GetConfig().formats.address)
+ toAddress: string;
+
+ @ApiProperty({ description: 'Amount of REALU shares to transfer (whole shares, REALU decimals = 0)' })
+ @IsNotEmpty()
+ @IsNumber()
+ @IsInt()
+ @Min(1)
+ @Type(() => Number)
+ amount: number;
+}
+
+export class RealUnitTransferConfirmDto extends Eip7702ConfirmDto {}
+
+// --- EIP-7702 Data DTO ---
+
+export class RealUnitTransferEip7702DataDto {
+ @ApiProperty({ description: 'Relayer address that will execute the transaction (W2W gas wallet)' })
+ relayerAddress: string;
+
+ @ApiProperty({ description: 'DelegationManager contract address' })
+ delegationManagerAddress: string;
+
+ @ApiProperty({ description: 'Delegator contract address' })
+ delegatorAddress: string;
+
+ @ApiProperty({ description: 'User account nonce for EIP-7702 authorization' })
+ userNonce: number;
+
+ @ApiProperty({ description: 'EIP-712 domain for delegation signature' })
+ domain: {
+ name: string;
+ version: string;
+ chainId: number;
+ verifyingContract: string;
+ };
+
+ @ApiProperty({ description: 'EIP-712 types for delegation signature' })
+ types: {
+ Delegation: Array<{ name: string; type: string }>;
+ Caveat: Array<{ name: string; type: string }>;
+ };
+
+ @ApiProperty({ description: 'Delegation message to sign' })
+ message: {
+ delegate: string;
+ delegator: string;
+ authority: string;
+ caveats: any[];
+ salt: number;
+ };
+
+ @ApiProperty({ description: 'REALU token contract address' })
+ tokenAddress: string;
+
+ @ApiProperty({ description: 'Amount in wei (token smallest unit)' })
+ amountWei: string;
+
+ @ApiProperty({ description: 'Recipient address (where the REALU shares will be sent)' })
+ recipient: string;
+}
+
+// --- Response DTO ---
+
+export class RealUnitTransferPaymentInfoDto {
+ @ApiProperty({ description: 'Transfer request ID (use for the confirm endpoint)' })
+ id: number;
+
+ @ApiProperty({ description: 'Transfer request UID' })
+ uid: string;
+
+ @ApiProperty({ description: 'Recipient wallet address (checksum-normalized)' })
+ toAddress: string;
+
+ @ApiProperty({ description: 'Amount of REALU shares to transfer' })
+ amount: number;
+
+ @ApiProperty({ description: 'REALU token contract address' })
+ tokenAddress: string;
+
+ @ApiProperty({ description: 'EVM chain ID' })
+ chainId: number;
+
+ @ApiProperty({ type: RealUnitTransferEip7702DataDto, description: 'EIP-7702 delegation data for gasless transfer' })
+ eip7702: RealUnitTransferEip7702DataDto;
+}
diff --git a/src/subdomains/supporting/realunit/entities/realunit-transfer-request.entity.ts b/src/subdomains/supporting/realunit/entities/realunit-transfer-request.entity.ts
new file mode 100644
index 0000000000..a36e06b40b
--- /dev/null
+++ b/src/subdomains/supporting/realunit/entities/realunit-transfer-request.entity.ts
@@ -0,0 +1,54 @@
+import { IEntity } from 'src/shared/models/entity';
+import { User } from 'src/subdomains/generic/user/models/user/user.entity';
+import { Column, Entity, Index, ManyToOne } from 'typeorm';
+
+// --- W2W TRANSFER --- //
+
+export enum RealUnitTransferRequestStatus {
+ CREATED = 'Created',
+ COMPLETED = 'Completed',
+}
+
+/**
+ * Server-side persisted intent for a user-initiated RealUnit wallet-to-wallet transfer.
+ *
+ * The user's EIP-7702 delegation is a blanket delegation (ROOT_AUTHORITY, no caveats): it does NOT
+ * cryptographically bind the specific recipient or amount — the backend supplies the ERC20 transfer
+ * call at execute time. Therefore the transfer intent (recipient + amount) is persisted at prepare
+ * time and reused verbatim at confirm; the confirm endpoint never relays recipient/amount taken from
+ * untrusted client input.
+ */
+@Entity()
+export class RealUnitTransferRequest extends IEntity {
+ @Column({ length: 256, unique: true })
+ uid: string;
+
+ @ManyToOne(() => User, { nullable: false, eager: true })
+ @Index()
+ user: User;
+
+ @Column({ length: 256 })
+ toAddress: string;
+
+ @Column({ type: 'float' })
+ amount: number;
+
+ @Column({ length: 256, default: RealUnitTransferRequestStatus.CREATED })
+ status: RealUnitTransferRequestStatus;
+
+ @Column({ length: 256, nullable: true })
+ txHash?: string;
+
+ // --- ENTITY METHODS --- //
+
+ get isComplete(): boolean {
+ return this.status === RealUnitTransferRequestStatus.COMPLETED;
+ }
+
+ complete(txHash: string): this {
+ this.status = RealUnitTransferRequestStatus.COMPLETED;
+ this.txHash = txHash;
+
+ return this;
+ }
+}
diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts
index a7d7a20cd6..fff4200ada 100644
--- a/src/subdomains/supporting/realunit/realunit.module.ts
+++ b/src/subdomains/supporting/realunit/realunit.module.ts
@@ -1,4 +1,5 @@
import { forwardRef, Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
import { EthereumModule } from 'src/integration/blockchain/ethereum/ethereum.module';
import { RealUnitBlockchainModule } from 'src/integration/blockchain/realunit/realunit-blockchain.module';
import { SepoliaModule } from 'src/integration/blockchain/sepolia/sepolia.module';
@@ -16,11 +17,14 @@ import { PaymentModule } from '../payment/payment.module';
import { TransactionModule } from '../payment/transaction.module';
import { PricingModule } from '../pricing/pricing.module';
import { RealUnitController } from './controllers/realunit.controller';
+import { RealUnitTransferRequest } from './entities/realunit-transfer-request.entity';
import { RealUnitDevService } from './realunit-dev.service';
import { RealUnitService } from './realunit.service';
+import { RealUnitTransferRequestRepository } from './repositories/realunit-transfer-request.repository';
@Module({
imports: [
+ TypeOrmModule.forFeature([RealUnitTransferRequest]),
SharedModule,
PricingModule,
BalanceModule,
@@ -39,7 +43,7 @@ import { RealUnitService } from './realunit.service';
FaucetRequestModule,
],
controllers: [RealUnitController],
- providers: [RealUnitService, RealUnitDevService],
+ providers: [RealUnitService, RealUnitDevService, RealUnitTransferRequestRepository],
exports: [RealUnitService],
})
export class RealUnitModule {}
diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts
index 1fa582aa37..77ccd4c68d 100644
--- a/src/subdomains/supporting/realunit/realunit.service.ts
+++ b/src/subdomains/supporting/realunit/realunit.service.ts
@@ -91,6 +91,13 @@ import {
RealUnitSellDto,
RealUnitSellPaymentInfoDto,
} from './dto/realunit-sell.dto';
+import {
+ RealUnitTransferConfirmDto,
+ RealUnitTransferDto,
+ RealUnitTransferPaymentInfoDto,
+} from './dto/realunit-transfer.dto';
+import { RealUnitTransferRequestStatus } from './entities/realunit-transfer-request.entity';
+import { RealUnitTransferRequestRepository } from './repositories/realunit-transfer-request.repository';
import {
AccountHistoryDto,
AccountSummaryDto,
@@ -155,6 +162,7 @@ export class RealUnitService {
private readonly swissQrService: SwissQRService,
private readonly feeService: FeeService,
private readonly faucetRequestService: FaucetRequestService,
+ private readonly transferRequestRepo: RealUnitTransferRequestRepository,
) {
this.ponderUrl = GetConfig().blockchain.realunit.graphUrl;
}
@@ -1498,4 +1506,168 @@ export class RealUnitService {
return { txHash };
}
+
+ // --- W2W TRANSFER --- //
+ // User-initiated RealUnit wallet-to-wallet transfer. DFX pays gas via the gasless EIP-7702 relay,
+ // but from a DEDICATED W2W gas-funding wallet (Config.blockchain.realunit.w2wGasWallet*) — never
+ // the Sell/OTC relayer. See issue DFXswiss/api #684 (umbrella #666).
+
+ async prepareTransfer(user: User, dto: RealUnitTransferDto): Promise {
+ const userData = user.userData;
+
+ // 1. Registration required (mirror getSellPaymentInfo)
+ if (!this.hasRegistrationForWallet(userData, user.address)) {
+ throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_SELL);
+ }
+
+ // 2. KYC Level check - Level 30 minimum
+ if (userData.kycLevel < KycLevel.LEVEL_30) {
+ throw new KycLevelRequiredException(
+ KycLevel.LEVEL_30,
+ userData.kycLevel,
+ 'KYC Level 30 required for RealUnit transfer',
+ KycContext.REALUNIT_SELL,
+ );
+ }
+
+ // The W2W transfer is a pure on-chain REALU->REALU self-custody movement and is therefore
+ // limit-exempt by design (consistent with #3819: trading limits are enforced at the fiat
+ // boundary). Gate only on registration + KYC30; do NOT add a misleading limit check.
+
+ // 3. Validate recipient + amount
+ const [realuAsset, zchfAsset] = await Promise.all([this.getRealuAsset(), this.getZchfAsset()]);
+ if (!realuAsset) throw new NotFoundException('REALU asset not found');
+
+ if (!ethers.utils.isAddress(dto.toAddress)) {
+ throw new BadRequestException('Invalid recipient address');
+ }
+ const toAddress = ethers.utils.getAddress(dto.toAddress);
+ const sender = ethers.utils.getAddress(user.address);
+
+ if (Util.equalsIgnoreCase(toAddress, sender)) {
+ throw new BadRequestException('Recipient must differ from sender');
+ }
+ const forbiddenAddresses = [realuAsset.chainId, zchfAsset?.chainId].filter((a) => a);
+ if (forbiddenAddresses.some((a) => Util.equalsIgnoreCase(a, toAddress))) {
+ throw new BadRequestException('Recipient must not be a token contract address');
+ }
+ if (!Number.isInteger(dto.amount) || dto.amount < 1) {
+ throw new BadRequestException('Amount must be a whole number of shares (>= 1)');
+ }
+
+ // 4. Preflight: dedicated W2W gas wallet must be configured and hold enough ETH for the relay
+ await this.assertW2wGasWalletFunded();
+
+ // 5. Prepare EIP-7702 delegation data the app must sign
+ const delegationData = await this.eip7702DelegationService.prepareDelegationDataForRealUnit(
+ sender,
+ realuAsset.blockchain,
+ );
+
+ // 6. Persist the transfer intent server-side (the delegation is blanket — recipient/amount are
+ // NOT bound by the signature, so they MUST be stored here and reused verbatim at confirm)
+ const transferRequest = await this.transferRequestRepo.save(
+ this.transferRequestRepo.create({
+ uid: Util.createUid(Config.prefixes.realUnitTransferUidPrefix),
+ user,
+ toAddress,
+ amount: dto.amount,
+ status: RealUnitTransferRequestStatus.CREATED,
+ }),
+ );
+
+ const amountWei = EvmUtil.toWeiAmount(dto.amount, realuAsset.decimals);
+
+ return {
+ id: transferRequest.id,
+ uid: transferRequest.uid,
+ toAddress,
+ amount: dto.amount,
+ tokenAddress: realuAsset.chainId,
+ chainId: realuAsset.evmChainId,
+ eip7702: {
+ ...delegationData,
+ tokenAddress: realuAsset.chainId,
+ amountWei: amountWei.toString(),
+ recipient: toAddress,
+ },
+ };
+ }
+
+ async confirmTransfer(
+ userId: number,
+ requestId: number,
+ dto: RealUnitTransferConfirmDto,
+ ): Promise<{ txHash: string }> {
+ // 1. Load stored transfer request with ownership check
+ const transferRequest = await this.transferRequestRepo.findOne({
+ where: { id: requestId },
+ relations: { user: { userData: true } },
+ });
+ if (!transferRequest || transferRequest.user?.id !== userId) {
+ throw new NotFoundException('Transfer request not found');
+ }
+ if (transferRequest.isComplete) throw new ConflictException('Transfer request is already confirmed');
+
+ const realuAsset = await this.getRealuAsset();
+ if (!realuAsset) throw new NotFoundException('REALU asset not found');
+
+ // 2. Defense-in-depth: delegator must match the request owner's address (contract also verifies)
+ if (!Util.equalsIgnoreCase(dto.delegation.delegator, transferRequest.user.address)) {
+ throw new BadRequestException('Delegation delegator does not match user address');
+ }
+
+ // 3. Dedicated W2W gas wallet pays gas — never the Sell/OTC relayer
+ const relayerPrivateKey = this.getW2wGasWalletPrivateKey();
+
+ // 4. Relay the STORED recipient + amount (never values from untrusted client input)
+ const txHash = await this.eip7702DelegationService.transferTokenWithUserDelegation(
+ transferRequest.user.address,
+ realuAsset,
+ transferRequest.toAddress,
+ transferRequest.amount,
+ dto.delegation,
+ dto.authorization,
+ relayerPrivateKey,
+ );
+
+ this.logger.info(`RealUnit W2W transfer confirmed via EIP-7702: ${txHash}`);
+
+ // 5. Mark request as complete
+ await this.transferRequestRepo.save(transferRequest.complete(txHash));
+
+ return { txHash };
+ }
+
+ // Returns the configured ETH balance of the dedicated W2W gas wallet (read-only — no key needed).
+ // Used by the monitoring observer; returns undefined if the wallet address is not configured.
+ async getW2wGasWalletBalance(): Promise {
+ const { w2wGasWalletAddress } = Config.blockchain.realunit;
+ if (!w2wGasWalletAddress) return undefined;
+
+ return this.getEvmClient().getNativeCoinBalanceForAddress(w2wGasWalletAddress);
+ }
+
+ private getW2wGasWalletPrivateKey(): `0x${string}` {
+ const { w2wGasWalletPrivateKey } = Config.blockchain.realunit;
+ if (!w2wGasWalletPrivateKey) {
+ throw new ServiceUnavailableException('W2W gas funding temporarily unavailable');
+ }
+ return (
+ w2wGasWalletPrivateKey.startsWith('0x') ? w2wGasWalletPrivateKey : `0x${w2wGasWalletPrivateKey}`
+ ) as `0x${string}`;
+ }
+
+ private async assertW2wGasWalletFunded(): Promise {
+ const { w2wGasWalletPrivateKey, w2wGasWalletAddress, w2wGasLowBalanceThreshold } = Config.blockchain.realunit;
+ if (!w2wGasWalletPrivateKey || !w2wGasWalletAddress) {
+ throw new ServiceUnavailableException('W2W gas funding temporarily unavailable');
+ }
+
+ const balance = await this.getEvmClient().getNativeCoinBalanceForAddress(w2wGasWalletAddress);
+ if (balance < w2wGasLowBalanceThreshold) {
+ // Clean message for the client; the balance observer raises the operator alert.
+ throw new ServiceUnavailableException('W2W gas funding temporarily unavailable');
+ }
+ }
}
diff --git a/src/subdomains/supporting/realunit/repositories/realunit-transfer-request.repository.ts b/src/subdomains/supporting/realunit/repositories/realunit-transfer-request.repository.ts
new file mode 100644
index 0000000000..fd2b195abd
--- /dev/null
+++ b/src/subdomains/supporting/realunit/repositories/realunit-transfer-request.repository.ts
@@ -0,0 +1,13 @@
+import { Injectable } from '@nestjs/common';
+import { BaseRepository } from 'src/shared/repositories/base.repository';
+import { EntityManager } from 'typeorm';
+import { RealUnitTransferRequest } from '../entities/realunit-transfer-request.entity';
+
+// --- W2W TRANSFER --- //
+
+@Injectable()
+export class RealUnitTransferRequestRepository extends BaseRepository {
+ constructor(manager: EntityManager) {
+ super(RealUnitTransferRequest, manager);
+ }
+}
From ab4e782eb3ca66af23d87983cd6cf1c0416ffe53 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 4 Jun 2026 10:26:02 +0200
Subject: [PATCH 2/4] fix(realunit): address review on W2W transfer
(deterministic migration 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)
---
.../1780560119568-AddRealUnitTransferRequest.js | 10 +++++-----
src/config/config.ts | 2 +-
src/subdomains/generic/kyc/enums/kyc.enum.ts | 2 ++
.../realunit/__tests__/realunit.service.spec.ts | 2 +-
.../realunit/dto/realunit-transfer.dto.ts | 13 ++++++++++++-
.../supporting/realunit/realunit.service.ts | 16 +++-------------
6 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/migration/1780560119568-AddRealUnitTransferRequest.js b/migration/1780560119568-AddRealUnitTransferRequest.js
index 4cf2820654..ab93deb18b 100644
--- a/migration/1780560119568-AddRealUnitTransferRequest.js
+++ b/migration/1780560119568-AddRealUnitTransferRequest.js
@@ -14,17 +14,17 @@ module.exports = class AddRealUnitTransferRequest1780560119568 {
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
- await queryRunner.query(`CREATE TABLE "real_unit_transfer_request" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "uid" character varying(256) NOT NULL, "toAddress" character varying(256) NOT NULL, "amount" double precision NOT NULL, "status" character varying(256) NOT NULL DEFAULT 'Created', "txHash" character varying(256), "userId" integer NOT NULL, CONSTRAINT "UQ_real_unit_transfer_request_uid" UNIQUE ("uid"), CONSTRAINT "PK_real_unit_transfer_request" PRIMARY KEY ("id"))`);
- await queryRunner.query(`CREATE INDEX "IDX_real_unit_transfer_request_userId" ON "real_unit_transfer_request" ("userId") `);
- await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" ADD CONSTRAINT "FK_real_unit_transfer_request_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+ await queryRunner.query(`CREATE TABLE "real_unit_transfer_request" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "uid" character varying(256) NOT NULL, "toAddress" character varying(256) NOT NULL, "amount" double precision NOT NULL, "status" character varying(256) NOT NULL DEFAULT 'Created', "txHash" character varying(256), "userId" integer NOT NULL, CONSTRAINT "UQ_93d6119c8606cddf2670d72b2d7" UNIQUE ("uid"), CONSTRAINT "PK_de3e9bfb56e01d7ed129a666692" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_9cdaf342da47974d7bded88063" ON "real_unit_transfer_request" ("userId") `);
+ await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" ADD CONSTRAINT "FK_9cdaf342da47974d7bded88063d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
- await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" DROP CONSTRAINT "FK_real_unit_transfer_request_userId"`);
- await queryRunner.query(`DROP INDEX "public"."IDX_real_unit_transfer_request_userId"`);
+ await queryRunner.query(`ALTER TABLE "real_unit_transfer_request" DROP CONSTRAINT "FK_9cdaf342da47974d7bded88063d"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_9cdaf342da47974d7bded88063"`);
await queryRunner.query(`DROP TABLE "real_unit_transfer_request"`);
}
}
diff --git a/src/config/config.ts b/src/config/config.ts
index 80dd785e72..0817f83a12 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -1063,7 +1063,7 @@ export class Configuration {
: '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d',
// Dedicated wallet-to-wallet (W2W) transfer gas-funding wallet. Separate from the Sell/OTC
// EIP-7702 relayer (per-chain `…WalletPrivateKey`): DFX pays gas for user-initiated REALU
- // W2W transfers from this wallet only. Operator provisions it (generate key, store in Vault,
+ // W2W transfers from this wallet only. The operator provisions it (generate key, store in Vault,
// fund with ETH) and sets the three env vars below.
w2wGasWalletPrivateKey: process.env.REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY?.split('
').join('\n'),
w2wGasWalletAddress: process.env.REALUNIT_W2W_GAS_WALLET_ADDRESS,
diff --git a/src/subdomains/generic/kyc/enums/kyc.enum.ts b/src/subdomains/generic/kyc/enums/kyc.enum.ts
index b25d793632..773ba919e3 100644
--- a/src/subdomains/generic/kyc/enums/kyc.enum.ts
+++ b/src/subdomains/generic/kyc/enums/kyc.enum.ts
@@ -109,6 +109,7 @@ export function getIdentificationType(type: IdentType, companyId: string): KycId
export enum KycContext {
REALUNIT_BUY = 'RealunitBuy',
REALUNIT_SELL = 'RealunitSell',
+ REALUNIT_TRANSFER = 'RealunitTransfer',
}
export function contextRequiredSteps(context: KycContext): Set | undefined {
@@ -123,6 +124,7 @@ export function contextRequiredSteps(context: KycContext): Set | un
KycStepName.IDENT,
]);
case KycContext.REALUNIT_SELL:
+ case KycContext.REALUNIT_TRANSFER:
return undefined;
}
}
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
index 340d5f55bc..7ad8ec7626 100644
--- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
@@ -28,8 +28,8 @@ import { AssetPricesService } from '../../pricing/services/asset-prices.service'
import { PricingService } from '../../pricing/services/pricing.service';
import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto';
import { RealUnitTransferRequestStatus } from '../entities/realunit-transfer-request.entity';
-import { RealUnitService } from '../realunit.service';
import { RealUnitDevService } from '../realunit-dev.service';
+import { RealUnitService } from '../realunit.service';
import { RealUnitTransferRequestRepository } from '../repositories/realunit-transfer-request.repository';
jest.mock('src/config/config', () => ({
diff --git a/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
index 677ef8002a..f5db3232c0 100644
--- a/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
+++ b/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts
@@ -28,6 +28,17 @@ export class RealUnitTransferConfirmDto extends Eip7702ConfirmDto {}
// --- EIP-7702 Data DTO ---
+// EIP-712 caveat entry (matches the DelegationManager `Caveat` struct: enforcer + terms). The
+// RealUnit blanket delegation carries no caveats, but the shape is typed precisely so no `any`
+// leaks into the signed payload the app receives.
+export class Eip712CaveatDto {
+ @ApiProperty({ description: 'Caveat enforcer contract address' })
+ enforcer: string;
+
+ @ApiProperty({ description: 'Caveat terms (ABI-encoded bytes)' })
+ terms: string;
+}
+
export class RealUnitTransferEip7702DataDto {
@ApiProperty({ description: 'Relayer address that will execute the transaction (W2W gas wallet)' })
relayerAddress: string;
@@ -60,7 +71,7 @@ export class RealUnitTransferEip7702DataDto {
delegate: string;
delegator: string;
authority: string;
- caveats: any[];
+ caveats: Eip712CaveatDto[];
salt: number;
};
diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts
index 77ccd4c68d..06676f6110 100644
--- a/src/subdomains/supporting/realunit/realunit.service.ts
+++ b/src/subdomains/supporting/realunit/realunit.service.ts
@@ -1517,7 +1517,7 @@ export class RealUnitService {
// 1. Registration required (mirror getSellPaymentInfo)
if (!this.hasRegistrationForWallet(userData, user.address)) {
- throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_SELL);
+ throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_TRANSFER);
}
// 2. KYC Level check - Level 30 minimum
@@ -1526,7 +1526,7 @@ export class RealUnitService {
KycLevel.LEVEL_30,
userData.kycLevel,
'KYC Level 30 required for RealUnit transfer',
- KycContext.REALUNIT_SELL,
+ KycContext.REALUNIT_TRANSFER,
);
}
@@ -1599,10 +1599,9 @@ export class RealUnitService {
requestId: number,
dto: RealUnitTransferConfirmDto,
): Promise<{ txHash: string }> {
- // 1. Load stored transfer request with ownership check
+ // 1. Load stored transfer request with ownership check (user is eager-loaded by the entity)
const transferRequest = await this.transferRequestRepo.findOne({
where: { id: requestId },
- relations: { user: { userData: true } },
});
if (!transferRequest || transferRequest.user?.id !== userId) {
throw new NotFoundException('Transfer request not found');
@@ -1639,15 +1638,6 @@ export class RealUnitService {
return { txHash };
}
- // Returns the configured ETH balance of the dedicated W2W gas wallet (read-only — no key needed).
- // Used by the monitoring observer; returns undefined if the wallet address is not configured.
- async getW2wGasWalletBalance(): Promise {
- const { w2wGasWalletAddress } = Config.blockchain.realunit;
- if (!w2wGasWalletAddress) return undefined;
-
- return this.getEvmClient().getNativeCoinBalanceForAddress(w2wGasWalletAddress);
- }
-
private getW2wGasWalletPrivateKey(): `0x${string}` {
const { w2wGasWalletPrivateKey } = Config.blockchain.realunit;
if (!w2wGasWalletPrivateKey) {
From 0b71d0dcbc6ce097facf7d6d0e60d80c08133e6c Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 4 Jun 2026 16:07:57 +0200
Subject: [PATCH 3/4] test(realunit): cover W2W transfer branches and relayer
override to 100%
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
---
.../__tests__/eip7702-brokerbot-sell.spec.ts | 60 +++++++++++++
.../realunit-w2w-gas.observer.spec.ts | 40 ++++++++-
.../kyc/enums/__tests__/kyc.enum.spec.ts | 27 ++++++
.../__tests__/realunit.controller.spec.ts | 49 +++++++++++
.../__tests__/realunit.service.spec.ts | 86 ++++++++++++++++++-
5 files changed, 257 insertions(+), 5 deletions(-)
create mode 100644 src/subdomains/generic/kyc/enums/__tests__/kyc.enum.spec.ts
create mode 100644 src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts
diff --git a/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts b/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
index abe2858048..83f8ea7a2b 100644
--- a/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
+++ b/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
@@ -118,6 +118,7 @@ jest.mock('../../evm.util', () => ({
import { Test, TestingModule } from '@nestjs/testing';
import * as viem from 'viem';
+import * as viemAccounts from 'viem/accounts';
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock';
import { AssetType } from 'src/shared/models/asset/asset.entity';
@@ -183,6 +184,65 @@ describe('Eip7702DelegationService - BrokerBot Sell', () => {
});
});
+ describe('transferTokenWithUserDelegation (W2W relayer override)', () => {
+ const recipient = '0xAaBbCcDdEeFf00112233445566778899AaBbCcDd';
+ const w2wRelayerKey = ('0x' + 'a'.repeat(64)) as `0x${string}`;
+
+ it('throws when delegation is supported neither generally nor for RealUnit', async () => {
+ const bitcoinToken = createCustomAsset({
+ blockchain: Blockchain.BITCOIN,
+ type: AssetType.TOKEN,
+ chainId: '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B',
+ decimals: 0,
+ name: 'REALU',
+ });
+
+ await expect(
+ service.transferTokenWithUserDelegation(
+ validUserAddress,
+ bitcoinToken,
+ recipient,
+ 5,
+ signedDelegation,
+ authorization,
+ w2wRelayerKey,
+ ),
+ ).rejects.toThrow('EIP-7702 delegation not supported for Bitcoin');
+ });
+
+ it('pays gas from the supplied W2W relayer key override (not the per-chain Sell relayer)', async () => {
+ const txHash = await service.transferTokenWithUserDelegation(
+ validUserAddress,
+ realuToken,
+ recipient,
+ 5,
+ signedDelegation,
+ authorization,
+ w2wRelayerKey,
+ );
+
+ expect(txHash).toBe('0xbrokerbottxhash');
+ // override path: the relayer account is derived from the override key, NOT the sepolia Sell relayer key
+ expect(viemAccounts.privateKeyToAccount).toHaveBeenCalledWith(w2wRelayerKey);
+ expect(viemAccounts.privateKeyToAccount).not.toHaveBeenCalledWith('0x' + '8'.repeat(64));
+ });
+
+ it('falls back to the per-chain Sell relayer key when no override is given', async () => {
+ const txHash = await service.transferTokenWithUserDelegation(
+ validUserAddress,
+ realuToken,
+ recipient,
+ 5,
+ signedDelegation,
+ authorization,
+ );
+
+ expect(txHash).toBe('0xbrokerbottxhash');
+ // default path: the relayer account is derived from the per-chain (sepolia) Sell relayer key
+ expect(viemAccounts.privateKeyToAccount).toHaveBeenCalledWith('0x' + '8'.repeat(64));
+ });
+ });
+
describe('executeBrokerBotSellForRealUnit', () => {
describe('Input Validation', () => {
it('should throw for unsupported blockchain (Ethereum in loc env)', async () => {
diff --git a/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
index 05d9413211..8e54f75205 100644
--- a/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
+++ b/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts
@@ -6,10 +6,17 @@ import { NotificationService } from 'src/subdomains/supporting/notification/serv
import { MonitoringService } from '../../monitoring.service';
import { RealUnitW2wGasObserver } from '../realunit-w2w-gas.observer';
+// Mutable so individual tests can exercise the address-unset branch and the mainnet (Ethereum) client
+// branch. jest.mock factories may only close over variables prefixed with `mock`.
+let mockEnvironment = 'loc';
+let mockW2wGasWalletAddress: string | undefined = '0xW2wGasWalletAddress';
+
jest.mock('src/config/config', () => {
const blockchain = {
realunit: {
- w2wGasWalletAddress: '0xW2wGasWalletAddress',
+ get w2wGasWalletAddress() {
+ return mockW2wGasWalletAddress;
+ },
w2wGasLowBalanceThreshold: 0.05,
},
ethereum: { ethChainId: 1 },
@@ -25,7 +32,7 @@ jest.mock('src/config/config', () => {
};
return {
get Config() {
- return { environment: 'loc', blockchain };
+ return { environment: mockEnvironment, blockchain };
},
Environment: { LOC: 'loc', DEV: 'dev', PRD: 'prd' },
GetConfig: jest.fn(() => ({
@@ -59,16 +66,20 @@ jest.mock('src/shared/services/dfx-logger', () => ({
describe('RealUnitW2wGasObserver', () => {
let observer: RealUnitW2wGasObserver;
let sepoliaClient: { getNativeCoinBalanceForAddress: jest.Mock };
+ let ethereumClient: { getNativeCoinBalanceForAddress: jest.Mock };
let notificationService: jest.Mocked;
beforeEach(async () => {
+ mockEnvironment = 'loc';
+ mockW2wGasWalletAddress = '0xW2wGasWalletAddress';
sepoliaClient = { getNativeCoinBalanceForAddress: jest.fn() };
+ ethereumClient = { getNativeCoinBalanceForAddress: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
RealUnitW2wGasObserver,
{ provide: MonitoringService, useValue: { register: jest.fn() } },
- { provide: EthereumService, useValue: { getDefaultClient: jest.fn() } },
+ { provide: EthereumService, useValue: { getDefaultClient: jest.fn().mockReturnValue(ethereumClient) } },
{ provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(sepoliaClient) } },
{ provide: NotificationService, useValue: { sendMail: jest.fn() } },
],
@@ -99,4 +110,27 @@ describe('RealUnitW2wGasObserver', () => {
expect(data.lowBalance).toBe(false);
expect(notificationService.sendMail).not.toHaveBeenCalled();
});
+
+ it('reports no low balance and skips the balance lookup when the gas wallet address is unset', async () => {
+ mockW2wGasWalletAddress = undefined;
+
+ const data = await observer.fetch();
+
+ expect(data.address).toBeUndefined();
+ expect(data.balance).toBeUndefined();
+ expect(data.lowBalance).toBe(false);
+ expect(sepoliaClient.getNativeCoinBalanceForAddress).not.toHaveBeenCalled();
+ expect(notificationService.sendMail).not.toHaveBeenCalled();
+ });
+
+ it('uses the Ethereum client on mainnet (PRD) environment', async () => {
+ mockEnvironment = 'prd';
+ ethereumClient.getNativeCoinBalanceForAddress.mockResolvedValue(1);
+
+ const data = await observer.fetch();
+
+ expect(ethereumClient.getNativeCoinBalanceForAddress).toHaveBeenCalledWith('0xW2wGasWalletAddress');
+ expect(sepoliaClient.getNativeCoinBalanceForAddress).not.toHaveBeenCalled();
+ expect(data.lowBalance).toBe(false);
+ });
});
diff --git a/src/subdomains/generic/kyc/enums/__tests__/kyc.enum.spec.ts b/src/subdomains/generic/kyc/enums/__tests__/kyc.enum.spec.ts
new file mode 100644
index 0000000000..1422b4c568
--- /dev/null
+++ b/src/subdomains/generic/kyc/enums/__tests__/kyc.enum.spec.ts
@@ -0,0 +1,27 @@
+import { KycStepName } from '../kyc-step-name.enum';
+import { contextRequiredSteps, KycContext } from '../kyc.enum';
+
+describe('contextRequiredSteps', () => {
+ it('returns the full required-step set for the RealUnit buy context', () => {
+ const steps = contextRequiredSteps(KycContext.REALUNIT_BUY);
+
+ expect(steps).toEqual(
+ new Set([
+ KycStepName.CONTACT_DATA,
+ KycStepName.PERSONAL_DATA,
+ KycStepName.NATIONALITY_DATA,
+ KycStepName.RECOMMENDATION,
+ KycStepName.RESIDENCE_PERMIT,
+ KycStepName.IDENT,
+ ]),
+ );
+ });
+
+ it('requires no extra steps for the RealUnit sell context', () => {
+ expect(contextRequiredSteps(KycContext.REALUNIT_SELL)).toBeUndefined();
+ });
+
+ it('requires no extra steps for the RealUnit transfer context', () => {
+ expect(contextRequiredSteps(KycContext.REALUNIT_TRANSFER)).toBeUndefined();
+ });
+});
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts
new file mode 100644
index 0000000000..c6b6c391b9
--- /dev/null
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts
@@ -0,0 +1,49 @@
+import { JwtPayload } from 'src/shared/auth/jwt-payload.interface';
+import { RealUnitController } from '../controllers/realunit.controller';
+
+// Thin controllers in this codebase delegate straight to the service; these specs assert the W2W transfer
+// endpoints wire the JWT/params/body through to the right service call (the service logic itself is covered
+// by realunit.service.spec.ts).
+describe('RealUnitController (W2W transfer)', () => {
+ let controller: RealUnitController;
+
+ const realunitService = {
+ prepareTransfer: jest.fn(),
+ confirmTransfer: jest.fn(),
+ };
+ const userService = { getUser: jest.fn() };
+
+ const jwt: JwtPayload = { user: 42, address: '0xUser' } as any;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ controller = new RealUnitController(realunitService as any, {} as any, userService as any, {} as any, {} as any);
+ });
+
+ describe('prepareTransfer', () => {
+ it('loads the user with kyc/country relations and delegates to the service', async () => {
+ const user = { id: 42 };
+ const dto = { toAddress: '0xRecipient', amount: 5 } as any;
+ userService.getUser.mockResolvedValue(user);
+ realunitService.prepareTransfer.mockResolvedValue({ id: 99 });
+
+ const result = await controller.prepareTransfer(jwt, dto);
+
+ expect(userService.getUser).toHaveBeenCalledWith(42, { userData: { kycSteps: true, country: true } });
+ expect(realunitService.prepareTransfer).toHaveBeenCalledWith(user, dto);
+ expect(result).toEqual({ id: 99 });
+ });
+ });
+
+ describe('confirmTransfer', () => {
+ it('delegates to the service with the parsed numeric id and confirm dto', async () => {
+ const dto = { delegation: {}, authorization: {} } as any;
+ realunitService.confirmTransfer.mockResolvedValue({ txHash: '0xhash' });
+
+ const result = await controller.confirmTransfer(jwt, '99', dto);
+
+ expect(realunitService.confirmTransfer).toHaveBeenCalledWith(42, 99, dto);
+ expect(result).toEqual({ txHash: '0xhash' });
+ });
+ });
+});
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
index 7ad8ec7626..3fd5f3d28b 100644
--- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
@@ -32,6 +32,12 @@ import { RealUnitDevService } from '../realunit-dev.service';
import { RealUnitService } from '../realunit.service';
import { RealUnitTransferRequestRepository } from '../repositories/realunit-transfer-request.repository';
+// Mutable so individual tests can exercise the W2W gas-wallet config branches (key unset / no 0x prefix /
+// address unset). Reset in beforeEach to the funded defaults. jest.mock factories may only close over
+// variables prefixed with `mock`.
+let mockW2wGasWalletPrivateKey: string | undefined = '0xW2wGasKey';
+let mockW2wGasWalletAddress: string | undefined = '0xW2wGasWalletAddress';
+
jest.mock('src/config/config', () => ({
get Config() {
return {
@@ -40,8 +46,8 @@ jest.mock('src/config/config', () => ({
blockchain: {
realunit: {
brokerbotAddress: '0xBrokerbotAddress',
- w2wGasWalletPrivateKey: '0xW2wGasKey',
- w2wGasWalletAddress: '0xW2wGasWalletAddress',
+ w2wGasWalletPrivateKey: mockW2wGasWalletPrivateKey,
+ w2wGasWalletAddress: mockW2wGasWalletAddress,
w2wGasLowBalanceThreshold: 0.05,
},
},
@@ -482,6 +488,12 @@ describe('RealUnitService', () => {
);
}
+ beforeEach(() => {
+ // reset mutable W2W gas-wallet config to the funded defaults
+ mockW2wGasWalletPrivateKey = '0xW2wGasKey';
+ mockW2wGasWalletAddress = '0xW2wGasWalletAddress';
+ });
+
describe('prepareTransfer', () => {
it('returns delegation data and persists the request with correct to/amount', async () => {
mockTransferAssets();
@@ -572,6 +584,40 @@ describe('RealUnitService', () => {
);
expect(transferRequestRepo.save).not.toHaveBeenCalled();
});
+
+ it('throws NotFound when the REALU asset is not found', async () => {
+ assetService.getAssetByQuery.mockImplementation(async (q: any) =>
+ q.name === 'REALU' ? undefined : transferZchfAsset,
+ );
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toThrow(
+ NotFoundException,
+ );
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
+
+ it('throws ServiceUnavailable when the W2W gas wallet private key is not configured', async () => {
+ mockTransferAssets();
+ mockW2wGasWalletPrivateKey = undefined;
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toThrow(
+ ServiceUnavailableException,
+ );
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
+
+ it('throws ServiceUnavailable when the W2W gas wallet address is not configured', async () => {
+ mockTransferAssets();
+ mockW2wGasWalletAddress = undefined;
+ const user = buildRegisteredUser(30);
+
+ await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toThrow(
+ ServiceUnavailableException,
+ );
+ expect(transferRequestRepo.save).not.toHaveBeenCalled();
+ });
});
describe('confirmTransfer', () => {
@@ -652,6 +698,42 @@ describe('RealUnitService', () => {
await expect(service.confirmTransfer(42, 99, wrongDto)).rejects.toThrow(BadRequestException);
expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled();
});
+
+ it('throws NotFound when the REALU asset is not found', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
+ assetService.getAssetByQuery.mockResolvedValue(undefined as any);
+
+ await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(NotFoundException);
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled();
+ });
+
+ it('throws ServiceUnavailable when the W2W gas wallet private key is not configured', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
+ assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
+ mockW2wGasWalletPrivateKey = undefined;
+
+ await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(ServiceUnavailableException);
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled();
+ });
+
+ it('prefixes a bare (non-0x) W2W gas wallet private key before relaying', async () => {
+ transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
+ assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
+ mockW2wGasWalletPrivateKey = 'W2wGasKeyNoPrefix'; // no 0x prefix -> exercises the `0x${...}` branch
+ eip7702DelegationService.transferTokenWithUserDelegation.mockResolvedValue(w2wTxHash);
+
+ await service.confirmTransfer(42, 99, confirmDto);
+
+ expect(eip7702DelegationService.transferTokenWithUserDelegation).toHaveBeenCalledWith(
+ senderAddress,
+ transferRealuAsset,
+ recipientAddress,
+ 5,
+ confirmDto.delegation,
+ confirmDto.authorization,
+ '0xW2wGasKeyNoPrefix', // 0x-normalized key
+ );
+ });
});
});
From 089f836e20744dab02562216bd938a1c9e54d525 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 4 Jun 2026 17:53:07 +0200
Subject: [PATCH 4/4] fix(realunit): set W2W transfer delegation delegate to
the W2W gas wallet (fixes on-chain InvalidDelegate revert)
---
.../__tests__/eip7702-brokerbot-sell.spec.ts | 35 +++++++++++++++
.../delegation/eip7702-delegation.service.ts | 19 ++++++--
.../__tests__/realunit.service.spec.ts | 45 ++++++++++++++++---
.../supporting/realunit/realunit.service.ts | 15 ++++++-
4 files changed, 104 insertions(+), 10 deletions(-)
diff --git a/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts b/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
index 83f8ea7a2b..0b744521d2 100644
--- a/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
+++ b/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts
@@ -243,6 +243,41 @@ describe('Eip7702DelegationService - BrokerBot Sell', () => {
});
});
+ // The delegation's `delegate` is embedded in the EIP-712 message the user signs and is checked
+ // on-chain against msg.sender of redeemDelegations. For W2W the redeemer is the dedicated W2W gas
+ // wallet, so the prepared delegate MUST be that wallet's address — otherwise the on-chain call
+ // reverts InvalidDelegate(). The Sell/OTC flow keeps using the per-chain relayer address.
+ describe('prepareDelegationDataForRealUnit (W2W delegate override)', () => {
+ // privateKeyToAccount is mocked to return this address; it is the per-chain Sell/OTC relayer that
+ // the default (sell) flow must keep embedding as the delegate.
+ const sellRelayerAddress = '0x1234567890123456789012345678901234567890';
+ const w2wGasWalletAddress = '0xfeEDFACE00000000000000000000000000001234';
+
+ it('embeds the supplied delegate override (W2W gas wallet) as delegate and relayerAddress', async () => {
+ const result = await service.prepareDelegationDataForRealUnit(
+ validUserAddress,
+ Blockchain.SEPOLIA,
+ w2wGasWalletAddress,
+ );
+
+ // delegate (signed by the user) == relayerAddress (returned to the app) == W2W gas wallet (redeemer)
+ expect(result.message.delegate).toBe(w2wGasWalletAddress);
+ expect(result.relayerAddress).toBe(w2wGasWalletAddress);
+ expect(result.message.delegator).toBe(validUserAddress);
+ // and NOT the Sell/OTC relayer that would otherwise trigger the on-chain InvalidDelegate() revert
+ expect(result.message.delegate).not.toBe(sellRelayerAddress);
+ });
+
+ it('uses the per-chain Sell relayer address as delegate when no override is given (sell flow unchanged)', async () => {
+ const result = await service.prepareDelegationDataForRealUnit(validUserAddress, Blockchain.SEPOLIA);
+
+ // default (sell/OTC) path: delegate == the relayer derived from the per-chain Sell key
+ expect(result.message.delegate).toBe(sellRelayerAddress);
+ expect(result.relayerAddress).toBe(sellRelayerAddress);
+ expect(viemAccounts.privateKeyToAccount).toHaveBeenCalledWith('0x' + '8'.repeat(64));
+ });
+ });
+
describe('executeBrokerBotSellForRealUnit', () => {
describe('Input Validation', () => {
it('should throw for unsupported blockchain (Ethereum in loc env)', async () => {
diff --git a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
index f57766e8ab..05e4c25f7b 100644
--- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
+++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts
@@ -183,10 +183,17 @@ export class Eip7702DelegationService {
/**
* Prepare delegation data for RealUnit (bypasses global disable)
* RealUnit app supports eth_sign, so EIP-7702 works unlike MetaMask
+ *
+ * `delegateAddressOverride` (optional) sets the delegation's `delegate` to a caller-supplied
+ * address instead of the per-chain Sell/OTC relayer. The MetaMask DelegationManager enforces
+ * `msg.sender === delegation.delegate` in `redeemDelegations`, so the delegate MUST equal the
+ * address that relays (pays gas) at confirm time. The RealUnit W2W transfer relays from the
+ * dedicated W2W gas wallet, so it passes that wallet's address here to keep delegate == redeemer.
*/
async prepareDelegationDataForRealUnit(
userAddress: string,
blockchain: Blockchain,
+ delegateAddressOverride?: string,
): Promise<{
relayerAddress: string;
delegationManagerAddress: string;
@@ -199,7 +206,7 @@ export class Eip7702DelegationService {
if (!this.isDelegationSupportedForRealUnit(blockchain)) {
throw new Error(`EIP-7702 delegation not supported for RealUnit on ${blockchain}`);
}
- return this._prepareDelegationDataInternal(userAddress, blockchain);
+ return this._prepareDelegationDataInternal(userAddress, blockchain, delegateAddressOverride);
}
/**
@@ -208,6 +215,7 @@ export class Eip7702DelegationService {
private async _prepareDelegationDataInternal(
userAddress: string,
blockchain: Blockchain,
+ delegateAddressOverride?: string,
): Promise<{
relayerAddress: string;
delegationManagerAddress: string;
@@ -228,8 +236,11 @@ export class Eip7702DelegationService {
const userNonce = Number(await publicClient.getTransactionCount({ address: userAddress as Address }));
+ // The delegate must equal the address that relays redeemDelegations (msg.sender). Default is the
+ // per-chain Sell/OTC relayer (which also redeems for sell/OTC); the W2W transfer overrides it with
+ // the dedicated W2W gas wallet address so the contract's `msg.sender == delegate` check passes.
const relayerPrivateKey = this.getRelayerPrivateKey(blockchain);
- const relayerAccount = privateKeyToAccount(relayerPrivateKey);
+ const relayerAddress = (delegateAddressOverride ?? privateKeyToAccount(relayerPrivateKey).address) as Address;
const salt = BigInt(Date.now());
const domain = getDelegationEip712Domain(chainConfig.chain.id);
@@ -237,7 +248,7 @@ export class Eip7702DelegationService {
// Delegation message
const message = {
- delegate: relayerAccount.address,
+ delegate: relayerAddress,
delegator: userAddress,
authority: ROOT_AUTHORITY,
caveats: [],
@@ -245,7 +256,7 @@ export class Eip7702DelegationService {
};
return {
- relayerAddress: relayerAccount.address,
+ relayerAddress,
delegationManagerAddress: DELEGATION_MANAGER_ADDRESS,
delegatorAddress: DELEGATOR_ADDRESS,
userNonce,
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
index 3fd5f3d28b..f7a3f372e9 100644
--- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
@@ -35,7 +35,12 @@ import { RealUnitTransferRequestRepository } from '../repositories/realunit-tran
// Mutable so individual tests can exercise the W2W gas-wallet config branches (key unset / no 0x prefix /
// address unset). Reset in beforeEach to the funded defaults. jest.mock factories may only close over
// variables prefixed with `mock`.
-let mockW2wGasWalletPrivateKey: string | undefined = '0xW2wGasKey';
+// The default key is a valid 32-byte private key so the prepare flow can derive the gas-wallet address
+// (ethers.Wallet(key).address) — the W2W delegation's `delegate` must equal that derived address.
+const mockW2wGasWalletKeyDefault = '0x' + '1'.repeat(64);
+// Address derived from mockW2wGasWalletKeyDefault via ethers.Wallet(key).address (delegate == redeemer).
+const mockW2wGasWalletAddressDerived = '0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A';
+let mockW2wGasWalletPrivateKey: string | undefined = mockW2wGasWalletKeyDefault;
let mockW2wGasWalletAddress: string | undefined = '0xW2wGasWalletAddress';
jest.mock('src/config/config', () => ({
@@ -490,7 +495,7 @@ describe('RealUnitService', () => {
beforeEach(() => {
// reset mutable W2W gas-wallet config to the funded defaults
- mockW2wGasWalletPrivateKey = '0xW2wGasKey';
+ mockW2wGasWalletPrivateKey = mockW2wGasWalletKeyDefault;
mockW2wGasWalletAddress = '0xW2wGasWalletAddress';
});
@@ -506,6 +511,7 @@ describe('RealUnitService', () => {
expect(eip7702DelegationService.prepareDelegationDataForRealUnit).toHaveBeenCalledWith(
senderAddress,
Blockchain.SEPOLIA,
+ mockW2wGasWalletAddressDerived,
);
expect(transferRequestRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
@@ -520,6 +526,35 @@ describe('RealUnitService', () => {
expect(result.eip7702.amountWei).toBe('5');
});
+ // Regression guard for the on-chain InvalidDelegate() revert (Sepolia tx that reverted because the
+ // prepared delegate was the Sell/OTC relayer, not the W2W gas wallet that relays at confirm).
+ // The delegation's `delegate` (== msg.sender of redeemDelegations) MUST be the W2W gas wallet
+ // address derived from the SAME private key confirmTransfer relays with — never getRelayerPrivateKey.
+ it('sets the delegation delegate to the W2W gas wallet (delegate == redeemer), not the Sell relayer', async () => {
+ mockTransferAssets();
+ sepoliaClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); // funded
+
+ // Echo the delegate override the service passes back into the prepared delegation message, exactly
+ // as the real prepareDelegationDataForRealUnit does, so we can assert delegate == W2W wallet.
+ eip7702DelegationService.prepareDelegationDataForRealUnit.mockImplementation(
+ async (_user: string, _chain: Blockchain, delegateAddressOverride?: string) =>
+ ({
+ ...delegationData,
+ relayerAddress: delegateAddressOverride,
+ message: { ...delegationData.message, delegate: delegateAddressOverride },
+ }) as any,
+ );
+
+ const user = buildRegisteredUser(30);
+ const result = await service.prepareTransfer(user, { toAddress: recipientAddress, amount: 5 });
+
+ // delegate / relayerAddress equal the address derived from the W2W gas wallet private key
+ expect(result.eip7702.relayerAddress).toBe(mockW2wGasWalletAddressDerived);
+ expect(result.eip7702.message.delegate).toBe(mockW2wGasWalletAddressDerived);
+ // and NOT the Sell/OTC relayer placeholder ('0xRelayer') the old code would have embedded
+ expect(result.eip7702.message.delegate).not.toBe('0xRelayer');
+ });
+
it('throws when registration is missing', async () => {
const user: any = {
id: 42,
@@ -665,7 +700,7 @@ describe('RealUnitService', () => {
5, // STORED amount, not from client
confirmDto.delegation,
confirmDto.authorization,
- '0xW2wGasKey', // dedicated W2W relayer key override
+ mockW2wGasWalletKeyDefault, // dedicated W2W relayer key override
);
});
@@ -719,7 +754,7 @@ describe('RealUnitService', () => {
it('prefixes a bare (non-0x) W2W gas wallet private key before relaying', async () => {
transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest());
assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset);
- mockW2wGasWalletPrivateKey = 'W2wGasKeyNoPrefix'; // no 0x prefix -> exercises the `0x${...}` branch
+ mockW2wGasWalletPrivateKey = '1'.repeat(64); // no 0x prefix -> exercises the `0x${...}` branch
eip7702DelegationService.transferTokenWithUserDelegation.mockResolvedValue(w2wTxHash);
await service.confirmTransfer(42, 99, confirmDto);
@@ -731,7 +766,7 @@ describe('RealUnitService', () => {
5,
confirmDto.delegation,
confirmDto.authorization,
- '0xW2wGasKeyNoPrefix', // 0x-normalized key
+ '0x' + '1'.repeat(64), // 0x-normalized key
);
});
});
diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts
index 06676f6110..f31e43a741 100644
--- a/src/subdomains/supporting/realunit/realunit.service.ts
+++ b/src/subdomains/supporting/realunit/realunit.service.ts
@@ -1558,10 +1558,16 @@ export class RealUnitService {
// 4. Preflight: dedicated W2W gas wallet must be configured and hold enough ETH for the relay
await this.assertW2wGasWalletFunded();
- // 5. Prepare EIP-7702 delegation data the app must sign
+ // 5. Prepare EIP-7702 delegation data the app must sign. The delegation's `delegate` MUST be the
+ // dedicated W2W gas wallet (the redeemer at confirm), NOT the Sell/OTC relayer — the
+ // DelegationManager enforces `msg.sender == delegate` in redeemDelegations, otherwise it reverts
+ // InvalidDelegate(). Derive the delegate from the same key confirmTransfer relays with so they
+ // always match.
+ const w2wGasWalletAddress = this.getW2wGasWalletAddress();
const delegationData = await this.eip7702DelegationService.prepareDelegationDataForRealUnit(
sender,
realuAsset.blockchain,
+ w2wGasWalletAddress,
);
// 6. Persist the transfer intent server-side (the delegation is blanket — recipient/amount are
@@ -1648,6 +1654,13 @@ export class RealUnitService {
) as `0x${string}`;
}
+ // Address that confirmTransfer actually relays redeemDelegations with (msg.sender). Derived from the
+ // SAME private key so the prepared delegation's `delegate` is guaranteed to match the redeemer and
+ // the DelegationManager's `msg.sender == delegate` check passes (otherwise: InvalidDelegate revert).
+ private getW2wGasWalletAddress(): string {
+ return new ethers.Wallet(this.getW2wGasWalletPrivateKey()).address;
+ }
+
private async assertW2wGasWalletFunded(): Promise {
const { w2wGasWalletPrivateKey, w2wGasWalletAddress, w2wGasLowBalanceThreshold } = Config.blockchain.realunit;
if (!w2wGasWalletPrivateKey || !w2wGasWalletAddress) {