diff --git a/.env.example b/.env.example index 7ef33f52ec..a99530e66f 100644 --- a/.env.example +++ b/.env.example @@ -315,6 +315,9 @@ REALUNIT_BANK_ADDRESS= REALUNIT_BANK_IBAN= REALUNIT_BANK_BIC= REALUNIT_BANK_NAME= +REALUNIT_W2W_GAS_WALLET_ADDRESS= +REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY=xxx +REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05 REQUEST_KNOWN_IPS= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1aff32f14f..ef9a5a0417 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -969,19 +969,21 @@ Keep old endpoints for backward compatibility but annotate: The RealUnit purchase and sale flows historically lived under `/v1/realunit/brokerbot/*`. That naming is misleading: most of those endpoints never touch the on-chain Brokerbot smart contract. Treat them as two distinct subsystems: -| Path | What it does | On-chain? | -|---|---|---| -| `GET /v1/realunit/quote/price` | Spot price per share | No — Aktionariat REST (`/directinvestment/getPrice`, 30 s cache) | -| `GET /v1/realunit/quote/buyPrice?shares=N` | `N × price` (buy direction) | No | -| `GET /v1/realunit/quote/buyShares?amount=N` | `floor(N / price)` (buy direction) | No | -| `GET /v1/realunit/quote/sellPrice?shares=N` | Estimated payout after user-specific fees | No — REST price + local fee math | -| `GET /v1/realunit/quote/sellShares?amount=N` | Reverse of the above | No | -| `GET /v1/realunit/quote/info` | Spot price + Brokerbot contract addresses (for clients that need them) | No | -| `PUT /v1/realunit/buy` + `/buy/:id/confirm` | Fiat IBAN flow — Aktionariat allocates shares off-chain via `directinvestment/payAndAllocate` | No | -| `PUT /v1/realunit/sell` | Anchors the quote against the live on-chain sell price before returning payment-info | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` (viem `readContract`) | -| `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` | +| Path | What it does | On-chain? | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| `GET /v1/realunit/quote/price` | Spot price per share | No — Aktionariat REST (`/directinvestment/getPrice`, 30 s cache) | +| `GET /v1/realunit/quote/buyPrice?shares=N` | `N × price` (buy direction) | No | +| `GET /v1/realunit/quote/buyShares?amount=N` | `floor(N / price)` (buy direction) | No | +| `GET /v1/realunit/quote/sellPrice?shares=N` | Estimated payout after user-specific fees | No — REST price + local fee math | +| `GET /v1/realunit/quote/sellShares?amount=N` | Reverse of the above | No | +| `GET /v1/realunit/quote/info` | Spot price + Brokerbot contract addresses (for clients that need them) | No | +| `PUT /v1/realunit/buy` + `/buy/:id/confirm` | Fiat IBAN flow — Aktionariat allocates shares off-chain via `directinvestment/payAndAllocate` | No | +| `PUT /v1/realunit/sell` | Anchors the quote against the live on-chain sell price before returning payment-info | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` (viem `readContract`) | +| `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: @@ -993,10 +995,10 @@ Operational consequences: The endpoint that tells the client what to do to RealUnit-register the connected wallet historically lived under `/v1/realunit/wallet/status`. That naming is misleading: the resource being described is the user's Aktionariat registration, not a generic wallet status — and clients never ask "what is the wallet's status?", they ask "what do I need to do to be RealUnit-registered?". The canonical path is now `/v1/realunit/registration`; the legacy path is kept as a `deprecated: true` mirror. -| Old | New | -|---|---| -| `GET /v1/realunit/wallet/status` | `GET /v1/realunit/registration` | -| `RealUnitWalletStatusDto` | `RealUnitRegistrationInfoDto` | +| Old | New | +| --------------------------------------------- | ------------------------------------------ | +| `GET /v1/realunit/wallet/status` | `GET /v1/realunit/registration` | +| `RealUnitWalletStatusDto` | `RealUnitRegistrationInfoDto` | | `RealUnitService.getAddressWalletStatus(...)` | `RealUnitService.getRegistrationInfo(...)` | Operational consequence: treat `/wallet/status` as deprecated; consume `state` from the new `/registration` endpoint; the legacy path is kept for backwards compatibility on existing clients only. @@ -1024,13 +1026,13 @@ new capability flag.** #### 1. Heterogeneous capabilities — `bool` for hide-able, struct for discoverable -| Action type | Schema | -|---|---| -| Hide-able (e.g. Edit button — UI just hides/disables it when forbidden) | `canEditName: boolean` | +| Action type | Schema | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Hide-able (e.g. Edit button — UI just hides/disables it when forbidden) | `canEditName: boolean` | | Discoverable (tile MUST stay visible; user is guided through a prerequisite) | `createSupportTicket: { available, missingPrerequisite? }` | Don't mix the two. Hide-able stays `bool`. Discoverable needs a -discriminator so the client knows *which* prerequisite to render. +discriminator so the client knows _which_ prerequisite to render. #### 2. Static info belongs in Swagger, NOT in the `/user` response diff --git a/migration/1784600000000-AddRealUnitTransferRequest.js b/migration/1784600000000-AddRealUnitTransferRequest.js new file mode 100644 index 0000000000..1638d463f7 --- /dev/null +++ b/migration/1784600000000-AddRealUnitTransferRequest.js @@ -0,0 +1,38 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddRealUnitTransferRequest1784600000000 { + name = 'AddRealUnitTransferRequest1784600000000'; + + /** + * @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_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_9cdaf342da47974d7bded88063d"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_9cdaf342da47974d7bded88063"`); + await queryRunner.query(`DROP TABLE "real_unit_transfer_request"`); + } +}; diff --git a/package.json b/package.json index fbde71f63f..096792ad43 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "ts" ], "rootDir": "src", + "setupFiles": ["/jest-env.setup.ts"], "moduleNameMapper": { "^src/(.*)$": "/$1", "^@dfinity/(ledger-icp|ledger-icrc|utils)$": "/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts", diff --git a/src/config/config.ts b/src/config/config.ts index 4992d6d47d..95b1a1ac78 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -98,6 +98,7 @@ export class Configuration { paymentLinkUidPrefix: 'pl', paymentLinkPaymentUidPrefix: 'plp', paymentQuoteUidPrefix: 'plq', + realUnitTransferUidPrefix: 'RT', }; moderators = { @@ -1108,6 +1109,19 @@ 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. 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, + w2wGasLowBalanceThreshold: (() => { + const raw = process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD; + if (raw === undefined) throw new Error('Missing REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD'); + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) throw new Error(`Invalid REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD: ${raw}`); + return n; + })(), // 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/__tests__/eip7702-brokerbot-sell.spec.ts b/src/integration/blockchain/shared/evm/delegation/__tests__/eip7702-brokerbot-sell.spec.ts index abe2858048..3e38dfc687 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 @@ -29,6 +29,7 @@ jest.mock('viem', () => ({ parseAbi: jest.fn().mockReturnValue([]), http: jest.fn(), recoverTypedDataAddress: jest.fn().mockResolvedValue(VALID_USER_ADDRESS), + keccak256: jest.fn().mockReturnValue('0xbrokerbottxhash'), })); jest.mock('viem/utils', () => ({ @@ -118,6 +119,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 +185,100 @@ 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)); + }); + }); + + // 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 08172fb6e0..1bd412a0ef 100644 --- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts +++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Config, Environment, GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; @@ -13,6 +13,7 @@ import { encodePacked, Hex, http, + keccak256, parseAbi, recoverTypedDataAddress, } from 'viem'; @@ -31,6 +32,13 @@ interface Eip7702Authorization { yParity: number; } +export class TransactionRevertedException extends Error { + constructor(public readonly txHash: string) { + super(`Transaction reverted on-chain: ${txHash}`); + this.name = 'TransactionRevertedException'; + } +} + // Contract addresses (same on all EVM chains via CREATE2) const DELEGATOR_ADDRESS = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b' as Address; const DELEGATION_MANAGER_ADDRESS = '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3' as Address; @@ -91,16 +99,18 @@ export class Eip7702DelegationService { private readonly logger = new DfxLogger(Eip7702DelegationService); private readonly config = GetConfig().blockchain; - // Sequential lock for relayer nonce management (prevents concurrent nonce collisions) - private nonceLock: Promise = Promise.resolve(); + // Sequential lock for relayer nonce management, keyed per relayer account address (prevents + // concurrent nonce collisions per account) — unrelated relayer accounts run in parallel, e.g. + // the RealUnit W2W gas wallet must never block the Sell/OTC per-chain relayer or vice versa. + private readonly nonceLocks = new Map>(); - private async withNonceLock(fn: () => Promise): Promise { + private async withNonceLock(lockKey: string, fn: () => Promise): Promise { let release: () => void; const lock = new Promise((resolve) => { release = resolve; }); - const previousLock = this.nonceLock; - this.nonceLock = lock; + const previousLock = this.nonceLocks.get(lockKey) ?? Promise.resolve(); + this.nonceLocks.set(lockKey, lock); await previousLock; try { return await fn(); @@ -184,10 +194,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; @@ -200,7 +217,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); } /** @@ -209,6 +226,7 @@ export class Eip7702DelegationService { private async _prepareDelegationDataInternal( userAddress: string, blockchain: Blockchain, + delegateAddressOverride?: string, ): Promise<{ relayerAddress: string; delegationManagerAddress: string; @@ -229,8 +247,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); @@ -238,7 +259,7 @@ export class Eip7702DelegationService { // Delegation message const message = { - delegate: relayerAccount.address, + delegate: relayerAddress, delegator: userAddress, authority: ROOT_AUTHORITY, caveats: [], @@ -246,7 +267,7 @@ export class Eip7702DelegationService { }; return { - relayerAddress: relayerAccount.address, + relayerAddress, delegationManagerAddress: DELEGATION_MANAGER_ADDRESS, delegatorAddress: DELEGATOR_ADDRESS, userNonce, @@ -259,6 +280,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, @@ -273,8 +298,10 @@ export class Eip7702DelegationService { signature: string; }, authorization: Eip7702Authorization, + relayerPrivateKeyOverride?: Hex, + onBroadcast?: (txHash: string) => Promise, ): 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( @@ -284,6 +311,8 @@ export class Eip7702DelegationService { amount, signedDelegation, authorization, + relayerPrivateKeyOverride, + onBroadcast, ); } @@ -372,10 +401,12 @@ export class Eip7702DelegationService { // Validate authorization fields if (Number(authorization.chainId) !== expectedChainId) { - throw new Error(`Authorization chainId mismatch: expected ${expectedChainId}, got ${authorization.chainId}`); + throw new BadRequestException( + `Authorization chainId mismatch: expected ${expectedChainId}, got ${authorization.chainId}`, + ); } if (authorization.address.toLowerCase() !== DELEGATOR_ADDRESS.toLowerCase()) { - throw new Error( + throw new BadRequestException( `Authorization contract address mismatch: expected ${DELEGATOR_ADDRESS}, got ${authorization.address}`, ); } @@ -477,7 +508,7 @@ export class Eip7702DelegationService { }; // Sign, broadcast and confirm within nonce lock to prevent concurrent nonce collisions - return this.withNonceLock(async () => { + return this.withNonceLock(relayerAccount.address, async () => { const nonce = await publicClient.getTransactionCount({ address: relayerAccount.address, blockTag: 'pending', @@ -535,6 +566,8 @@ export class Eip7702DelegationService { signature: string; }, authorization: Eip7702Authorization, + relayerPrivateKeyOverride?: Hex, + onBroadcast?: (txHash: string) => Promise, ): Promise { const blockchain = token.blockchain; @@ -558,10 +591,12 @@ export class Eip7702DelegationService { // Validate authorization fields if (Number(authorization.chainId) !== expectedChainId) { - throw new Error(`Authorization chainId mismatch: expected ${expectedChainId}, got ${authorization.chainId}`); + throw new BadRequestException( + `Authorization chainId mismatch: expected ${expectedChainId}, got ${authorization.chainId}`, + ); } if (authorization.address.toLowerCase() !== DELEGATOR_ADDRESS.toLowerCase()) { - throw new Error( + throw new BadRequestException( `Authorization contract address mismatch: expected ${DELEGATOR_ADDRESS}, got ${authorization.address}`, ); } @@ -572,8 +607,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 @@ -648,7 +683,7 @@ export class Eip7702DelegationService { }; // Sign, broadcast and confirm within nonce lock to prevent concurrent nonce collisions - return this.withNonceLock(async () => { + return this.withNonceLock(relayerAccount.address, async () => { const nonce = await publicClient.getTransactionCount({ address: relayerAccount.address, blockTag: 'pending', @@ -670,7 +705,20 @@ export class Eip7702DelegationService { }; const signedTx = await walletClient.signTransaction(transaction as any); + const computedTxHash = keccak256(signedTx as `0x${string}`); + + // Persist the tx hash BEFORE it is sent to the node: a crash/restart between signing and mining + // must never lose track of an already-signed tx. With this ordering, "PROCESSING without a + // persisted txHash" strictly means "never signed/broadcast, safe to mark FAILED" (see + // RealUnitService.reconcilePendingTransfers). + if (onBroadcast) await onBroadcast(computedTxHash); + const txHash = await walletClient.sendRawTransaction({ serializedTransaction: signedTx as `0x${string}` }); + if (txHash !== computedTxHash) { + throw new Error( + `Broadcast tx hash ${txHash} does not match locally computed hash ${computedTxHash} for user delegation transfer`, + ); + } this.logger.info( `User delegation transfer broadcast on ${blockchain}: ` + @@ -681,7 +729,7 @@ export class Eip7702DelegationService { const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash, timeout: 60_000 }); if (receipt.status === 'reverted') { - throw new Error(`Transaction reverted on-chain: ${txHash}`); + throw new TransactionRevertedException(txHash); } this.logger.info( @@ -939,7 +987,9 @@ export class Eip7702DelegationService { }); if (recoveredAddress.toLowerCase() !== expectedSigner.toLowerCase()) { - throw new Error(`Invalid delegation signature: recovered ${recoveredAddress}, expected ${expectedSigner}`); + throw new BadRequestException( + `Invalid delegation signature: recovered ${recoveredAddress}, expected ${expectedSigner}`, + ); } } @@ -962,7 +1012,9 @@ export class Eip7702DelegationService { }); if (recoveredAddress.toLowerCase() !== expectedSigner.toLowerCase()) { - throw new Error(`Invalid authorization signature: recovered ${recoveredAddress}, expected ${expectedSigner}`); + throw new BadRequestException( + `Invalid authorization signature: recovered ${recoveredAddress}, expected ${expectedSigner}`, + ); } } diff --git a/src/jest-env.setup.ts b/src/jest-env.setup.ts new file mode 100644 index 0000000000..3da86eb4f1 --- /dev/null +++ b/src/jest-env.setup.ts @@ -0,0 +1,9 @@ +// Jest-only test fixture defaults for env vars that config.ts requires fail-loud in production. +// This file is wired via package.json "jest.setupFiles" and runs before every test file, BEFORE +// any `new Configuration()`/`GetConfig()` call (directly or via TestUtil.provideConfig). It must +// NEVER be imported by production code — it exists solely so the test suite doesn't have to set +// every required-but-irrelevant-to-the-test env var in 30+ individual spec files. This is not a +// silent production fallback: config.ts still throws fail-loud for a real, unset boot. +if (!process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD) { + process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD = '0.05'; +} diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index bcd68bba68..c41778f409 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -78,6 +78,7 @@ export enum Process { TX_REQUEST = 'TxRequest', TX_REQUEST_WAITING_EXPIRY = 'TxRequestWaitingExpiry', REALUNIT_QUOTE_COMPLETION = 'RealUnitQuoteCompletion', + REALUNIT_TRANSFER_RECONCILIATION = 'RealUnitTransferReconciliation', ORGANIZATION_SYNC = 'OrganizationSync', BANK_TX_RETURN = 'BankTxReturn', BANK_TX_RETURN_MAIL = 'BankTxReturnMail', 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..8e54f75205 --- /dev/null +++ b/src/subdomains/core/monitoring/observers/__tests__/realunit-w2w-gas.observer.spec.ts @@ -0,0 +1,136 @@ +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'; + +// 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: { + get w2wGasWalletAddress() { + return mockW2wGasWalletAddress; + }, + 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: mockEnvironment, 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 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().mockReturnValue(ethereumClient) } }, + { 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(); + }); + + 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/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/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/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/bank/bank-account/bank-account.service.ts b/src/subdomains/supporting/bank/bank-account/bank-account.service.ts index fc1066fc76..7a7cbce1bb 100644 --- a/src/subdomains/supporting/bank/bank-account/bank-account.service.ts +++ b/src/subdomains/supporting/bank/bank-account/bank-account.service.ts @@ -5,7 +5,7 @@ import { CountryService } from 'src/shared/models/country/country.service'; import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; import { KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; -import { Equal, IsNull, Not } from 'typeorm'; +import { Equal, IsNull, Like, Not } from 'typeorm'; import { BankAccount, BankAccountInfos } from './bank-account.entity'; import { BankAccountRepository } from './bank-account.repository'; @@ -43,6 +43,14 @@ export class BankAccountService { } } + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.BANK_ACCOUNT, timeout: 3600 }) + async reloadErrorBankAccounts(): Promise { + const bankAccounts = await this.bankAccountRepo.findBy({ result: Like('Error:%') }); + for (const bankAccount of bankAccounts) { + await this.reloadBankAccount(bankAccount); + } + } + @DfxCron(CronExpression.EVERY_10_MINUTES, { process: Process.BANK_ACCOUNT, timeout: 3600 }) async reloadUncheckedBankAccounts(): Promise { const bankAccounts = await this.bankAccountRepo.findBy({ result: IsNull(), iban: Not(IsNull()) }); 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..6858390a0d --- /dev/null +++ b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts @@ -0,0 +1,50 @@ +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 userData 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); + + // Registration is checked via AktionariatRegistrationRepository (async), not kycSteps. + expect(userService.getUser).toHaveBeenCalledWith(42, { userData: 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 9bf218d980..0ece66fa03 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -8,7 +8,10 @@ import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realu import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { Eip7702DelegationService } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; +import { + Eip7702DelegationService, + TransactionRevertedException, +} from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; import { FaucetRequestService } from 'src/subdomains/core/faucet-request/services/faucet-request.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; @@ -39,7 +42,7 @@ import { LogSeverity } from 'src/subdomains/supporting/log/log.entity'; import { LogService } from 'src/subdomains/supporting/log/log.service'; import { SupportIssueReason, SupportIssueType } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; -import { FindOperator } from 'typeorm'; +import { FindOperator, IsNull } from 'typeorm'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitAktionariatConfirmationStatus } from '../dto/realunit-confirm-aktionariat.dto'; @@ -53,7 +56,9 @@ import { import { PriceInvalidException } from '../../pricing/domain/exceptions/price-invalid.exception'; import { RealUnitDevService } from '../realunit-dev.service'; import { AktionariatRegistration } from '../entities/aktionariat-registration.entity'; +import { RealUnitTransferRequestStatus } from '../entities/realunit-transfer-request.entity'; import { AktionariatRegistrationRepository } from '../repositories/aktionariat-registration.repository'; +import { RealUnitTransferRequestRepository } from '../repositories/realunit-transfer-request.repository'; import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception'; import { KycLevelRequiredException, RegistrationRequiredException } from '../exceptions/buy-exceptions'; import { RealUnitService } from '../realunit.service'; @@ -61,15 +66,31 @@ import { RealUnitService } from '../realunit.service'; let mockEnvironment = 'loc'; let mockAktionariatUrl: string | undefined = 'https://mock-aktionariat.example.com'; +// 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`. +// 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 = mockW2wGasWalletAddressDerived; + jest.mock('src/config/config', () => ({ get Config() { return { environment: mockEnvironment, txRequestWaitingExpiryDays: 7, + prefixes: { realUnitTransferUidPrefix: 'RT' }, blockchain: { realunit: { api: { url: 'https://mock-api.example.com', key: 'mock-key' }, aktionariatUrl: mockAktionariatUrl, + brokerbotAddress: '0xBrokerbotAddress', + w2wGasWalletPrivateKey: mockW2wGasWalletPrivateKey, + w2wGasWalletAddress: mockW2wGasWalletAddress, + w2wGasLowBalanceThreshold: 0.05, }, }, }; @@ -140,6 +161,7 @@ jest.mock('src/shared/utils/util', () => ({ isoDate: (date: Date) => date.toISOString().split('T')[0], daysBefore: (days: number, from?: Date) => new Date((from ?? new Date()).getTime() - days * 86_400_000), daysDiff: jest.fn().mockReturnValue(0), + minutesBefore: (minutes: number, from?: Date) => new Date((from ?? new Date()).getTime() - minutes * 60_000), // The service stamps a per-write uniqueness nonce into every audit message; return a distinct value on // each call so two byte-identical events serialise to different messages (mirrors the real randomness). randomString: (() => { @@ -172,6 +194,8 @@ describe('RealUnitService', () => { let fiatService: jest.Mocked; let buyService: jest.Mocked; let supportIssueService: jest.Mocked; + let transferRequestRepo: jest.Mocked; + let sepoliaClient: { getNativeCoinBalanceForAddress: jest.Mock; getTokenBalance: jest.Mock; getTxReceipt: jest.Mock }; const realuAsset = createCustomAsset({ id: 1, @@ -204,6 +228,8 @@ describe('RealUnitService', () => { aktionariatManager = { transaction: jest.fn(async (cb: any) => cb(aktionariatTxManager)), }; + sepoliaClient = { getNativeCoinBalanceForAddress: jest.fn(), getTokenBalance: jest.fn(), getTxReceipt: jest.fn() }; + const module: TestingModule = await Test.createTestingModule({ providers: [ RealUnitService, @@ -249,6 +275,8 @@ describe('RealUnitService', () => { provide: Eip7702DelegationService, useValue: { executeBrokerBotSellForRealUnit: jest.fn(), + prepareDelegationDataForRealUnit: jest.fn(), + transferTokenWithUserDelegation: jest.fn(), }, }, { @@ -267,7 +295,12 @@ describe('RealUnitService', () => { { provide: FeeService, useValue: {} }, { provide: FaucetRequestService, useValue: {} }, { provide: EthereumService, useValue: {} }, - { provide: SepoliaService, useValue: {} }, + { + provide: SepoliaService, + useValue: { + getDefaultClient: jest.fn().mockReturnValue(sepoliaClient), + }, + }, { provide: AktionariatRegistrationRepository, useValue: { @@ -290,6 +323,16 @@ describe('RealUnitService', () => { createIssueInternal: jest.fn(), }, }, + { + provide: RealUnitTransferRequestRepository, + useValue: { + create: jest.fn((e) => e), + save: jest.fn((e) => Promise.resolve({ id: 99, ...e })), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + }, + }, ], }).compile(); @@ -307,6 +350,7 @@ describe('RealUnitService', () => { fiatService = module.get(FiatService); buyService = module.get(BuyService); supportIssueService = module.get(SupportIssueService); + transferRequestRepo = module.get(RealUnitTransferRequestRepository); }); afterEach(() => { @@ -895,6 +939,536 @@ 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 { + return { + id: 42, + address: senderAddress, + userData: { + kycLevel, + }, + }; + } + + function mockTransferAssets(): void { + assetService.getAssetByQuery.mockImplementation(async (q: any) => + q.name === 'REALU' ? transferRealuAsset : transferZchfAsset, + ); + } + + beforeEach(() => { + // Registration is async (aktionariat_registration) on develop — spy the gate directly. + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(true); + // reset mutable W2W gas-wallet config to the funded defaults + mockW2wGasWalletPrivateKey = mockW2wGasWalletKeyDefault; + mockW2wGasWalletAddress = mockW2wGasWalletAddressDerived; + sepoliaClient.getTokenBalance.mockResolvedValue(999); + }); + + 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, + mockW2wGasWalletAddressDerived, + ); + 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'); + }); + + // 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 () => { + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(false); + const user = buildRegisteredUser(30); + + await expect(service.prepareTransfer(user, { toAddress: recipientAddress, amount: 1 })).rejects.toBeInstanceOf( + RegistrationRequiredException, + ); + 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.toBeInstanceOf( + KycLevelRequiredException, + ); + 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(); + }); + + 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', () => { + const confirmDto: any = { + delegation: { + delegator: senderAddress, + delegate: mockW2wGasWalletAddressDerived, + authority: '0xRoot', + salt: '1', + signature: '0xSig', + }, + authorization: { chainId: 11155111, address: '0xDelegator', nonce: 0, r: '0xR', s: '0xS', yParity: 0 }, + }; + + function buildStoredRequest(overrides: any = {}): any { + const request: any = { + id: 99, + uid: 'RTabc', + toAddress: recipientAddress, + amount: 5, + status: RealUnitTransferRequestStatus.CREATED, + user: { id: 42, address: senderAddress, userData: {} }, + complete: jest.fn(function (this: any, txHash: string) { + this.status = RealUnitTransferRequestStatus.COMPLETED; + this.txHash = txHash; + return this; + }), + fail: jest.fn(function (this: any) { + this.status = RealUnitTransferRequestStatus.FAILED; + return this; + }), + ...overrides, + }; + Object.defineProperty(request, 'isComplete', { + get() { + return request.status === RealUnitTransferRequestStatus.COMPLETED; + }, + configurable: true, + }); + return request; + } + + 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, + mockW2wGasWalletKeyDefault, // dedicated W2W relayer key override + expect.any(Function), // onBroadcast callback that persists txHash before the receipt wait + ); + }); + + 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('returns the stored txHash immediately for an already-completed request, without any balance/relay calls', async () => { + transferRequestRepo.findOne.mockResolvedValue( + buildStoredRequest({ status: RealUnitTransferRequestStatus.COMPLETED, txHash: w2wTxHash }), + ); + + const result = await service.confirmTransfer(42, 99, confirmDto); + + expect(result.txHash).toBe(w2wTxHash); + expect(assetService.getAssetByQuery).not.toHaveBeenCalled(); + expect(sepoliaClient.getTokenBalance).not.toHaveBeenCalled(); + expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled(); + }); + + 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(); + }); + + it('throws BadRequest when the delegate does not match the W2W gas wallet', async () => { + transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest()); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + sepoliaClient.getTokenBalance.mockResolvedValue(999); + + const wrongDto = { ...confirmDto, delegation: { ...confirmDto.delegation, delegate: '0xWrongDelegate' } }; + + await expect(service.confirmTransfer(42, 99, wrongDto)).rejects.toThrow(BadRequestException); + expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled(); + }); + + it('throws BadRequest when the sender does not hold enough REALU', async () => { + transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest()); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + sepoliaClient.getTokenBalance.mockResolvedValue(4); // stored request amount is 5 + + await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(BadRequestException); + expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled(); + }); + + it('throws when the configured W2W gas wallet address does not match the key-derived address', async () => { + transferRequestRepo.findOne.mockResolvedValue(buildStoredRequest()); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + mockW2wGasWalletAddress = '0xSomeOtherAddressThatDoesNotMatch'; + + await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow( + 'REALUNIT_W2W_GAS_WALLET_ADDRESS does not match the address derived from REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY', + ); + }); + + 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 = '1'.repeat(64); // 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, + '0x' + '1'.repeat(64), // 0x-normalized key + expect.any(Function), + ); + }); + + it('is idempotent: a second confirm on an already-broadcast request returns the same txHash without relaying again or re-checking balance', async () => { + const storedRequest = buildStoredRequest(); + transferRequestRepo.findOne.mockResolvedValue(storedRequest); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + eip7702DelegationService.transferTokenWithUserDelegation.mockImplementation(async (...args: any[]) => { + const onBroadcast = args[args.length - 1]; + if (typeof onBroadcast === 'function') await onBroadcast(w2wTxHash); + return w2wTxHash; + }); + + const firstResult = await service.confirmTransfer(42, 99, confirmDto); + expect(firstResult.txHash).toBe(w2wTxHash); + expect(eip7702DelegationService.transferTokenWithUserDelegation).toHaveBeenCalledTimes(1); + expect(sepoliaClient.getTokenBalance).toHaveBeenCalledTimes(1); + + // Model the persisted state after the first call: the onBroadcast callback already persisted + // txHash via transferRequestRepo.update before the receipt wait, and the request completed. + storedRequest.txHash = w2wTxHash; + storedRequest.status = RealUnitTransferRequestStatus.COMPLETED; + + // Drain the balance to below the transfer amount, as a real successful transfer would leave it — + // proves the retry short-circuits BEFORE the balance check (the old, buggy code would 409 here + // instead of returning the hash, because 0 < amount). + sepoliaClient.getTokenBalance.mockResolvedValue(0); + + const secondResult = await service.confirmTransfer(42, 99, confirmDto); + + expect(secondResult.txHash).toBe(w2wTxHash); + // still 1 — the second call short-circuits on the idempotency shortcut, no second relay + expect(eip7702DelegationService.transferTokenWithUserDelegation).toHaveBeenCalledTimes(1); + // still 1 — the shortcut returns before the balance check is ever reached again + expect(sepoliaClient.getTokenBalance).toHaveBeenCalledTimes(1); + }); + + it('marks the request FAILED when the relay reverts on-chain, and a retry does not return the reverted hash as success', async () => { + const storedRequest = buildStoredRequest(); + transferRequestRepo.findOne.mockResolvedValueOnce(storedRequest); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + eip7702DelegationService.transferTokenWithUserDelegation.mockImplementation(async (...args: any[]) => { + const onBroadcast = args[args.length - 1]; + if (typeof onBroadcast === 'function') await onBroadcast(w2wTxHash); + throw new TransactionRevertedException(w2wTxHash); + }); + + await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(TransactionRevertedException); + + expect(transferRequestRepo.update).toHaveBeenCalledWith(99, { + status: RealUnitTransferRequestStatus.FAILED, + }); + + // Retry after the revert: model the persisted FAILED+txHash state. The idempotency shortcut + // excludes FAILED, so this falls through into the atomic claim (WHERE status=CREATED), which + // matches nothing -> Conflict, instead of returning the reverted hash as a false success. + transferRequestRepo.findOne.mockResolvedValueOnce({ + ...storedRequest, + txHash: w2wTxHash, + status: RealUnitTransferRequestStatus.FAILED, + }); + transferRequestRepo.update.mockResolvedValueOnce({ affected: 0, raw: [], generatedMaps: [] }); + + await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(ConflictException); + }); + + it('throws Conflict when the request is stuck in PROCESSING without a txHash (no retry after a non-terminal state)', async () => { + transferRequestRepo.findOne.mockResolvedValue( + buildStoredRequest({ status: RealUnitTransferRequestStatus.PROCESSING }), + ); + assetService.getAssetByQuery.mockResolvedValue(transferRealuAsset); + transferRequestRepo.update.mockResolvedValueOnce({ affected: 0, raw: [], generatedMaps: [] }); // WHERE status=CREATED matches nothing + + await expect(service.confirmTransfer(42, 99, confirmDto)).rejects.toThrow(ConflictException); + expect(eip7702DelegationService.transferTokenWithUserDelegation).not.toHaveBeenCalled(); + }); + }); + + describe('reconcilePendingTransfers', () => { + function buildStaleTransferRequest(overrides: any = {}): any { + return { + id: 1, + uid: 'RTstale', + toAddress: '0x0000000000000000000000000000000000dEaD', + amount: 1, + status: RealUnitTransferRequestStatus.PROCESSING, + user: { id: 1 }, + txHash: null, + ...overrides, + }; + } + + it('marks a stale PROCESSING request with no txHash FAILED via conditional update (not save)', async () => { + transferRequestRepo.find.mockResolvedValue([buildStaleTransferRequest({ id: 5, txHash: null })]); + transferRequestRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + + await service.reconcilePendingTransfers(); + + expect(transferRequestRepo.update).toHaveBeenCalledWith( + { id: 5, status: RealUnitTransferRequestStatus.PROCESSING, txHash: IsNull() }, + { status: RealUnitTransferRequestStatus.FAILED }, + ); + expect(transferRequestRepo.save).not.toHaveBeenCalled(); + }); + + it('does not mark FAILED when a concurrent broadcast already set txHash (affected=0 skip)', async () => { + transferRequestRepo.find.mockResolvedValue([buildStaleTransferRequest({ id: 6, txHash: null })]); + transferRequestRepo.update.mockResolvedValue({ affected: 0, raw: [], generatedMaps: [] }); + + await service.reconcilePendingTransfers(); + + expect(transferRequestRepo.update).toHaveBeenCalledTimes(1); + expect(transferRequestRepo.update).toHaveBeenCalledWith( + { id: 6, status: RealUnitTransferRequestStatus.PROCESSING, txHash: IsNull() }, + { status: RealUnitTransferRequestStatus.FAILED }, + ); + expect(transferRequestRepo.save).not.toHaveBeenCalled(); + // No second corrective update — silent skip when affected=0 + expect(transferRequestRepo.update).toHaveBeenCalledTimes(1); + }); + + it('marks COMPLETED via conditional update when on-chain receipt status is 1', async () => { + transferRequestRepo.find.mockResolvedValue([buildStaleTransferRequest({ id: 7, txHash: '0xabc' })]); + sepoliaClient.getTxReceipt.mockResolvedValue({ status: 1 }); + transferRequestRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + + await service.reconcilePendingTransfers(); + + expect(sepoliaClient.getTxReceipt).toHaveBeenCalledWith('0xabc'); + expect(transferRequestRepo.update).toHaveBeenCalledWith( + { id: 7, status: RealUnitTransferRequestStatus.PROCESSING }, + { status: RealUnitTransferRequestStatus.COMPLETED, txHash: '0xabc' }, + ); + expect(transferRequestRepo.save).not.toHaveBeenCalled(); + }); + + it('marks FAILED via conditional update when on-chain receipt is reverted (status !== 1)', async () => { + transferRequestRepo.find.mockResolvedValue([buildStaleTransferRequest({ id: 8, txHash: '0xdef' })]); + sepoliaClient.getTxReceipt.mockResolvedValue({ status: 0 }); + transferRequestRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + + await service.reconcilePendingTransfers(); + + expect(sepoliaClient.getTxReceipt).toHaveBeenCalledWith('0xdef'); + expect(transferRequestRepo.update).toHaveBeenCalledWith( + { id: 8, status: RealUnitTransferRequestStatus.PROCESSING }, + { status: RealUnitTransferRequestStatus.FAILED }, + ); + expect(transferRequestRepo.save).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 cb79fcf70b..7884ec432e 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -23,6 +23,7 @@ import { ApiConflictResponse, ApiExcludeEndpoint, ApiForbiddenResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, @@ -84,6 +85,11 @@ import { RealUnitSellDto, RealUnitSellPaymentInfoDto, } from '../dto/realunit-sell.dto'; +import { + RealUnitTransferConfirmDto, + RealUnitTransferDto, + RealUnitTransferPaymentInfoDto, +} from '../dto/realunit-transfer.dto'; import { AccountHistoryDto, AccountHistoryQueryDto, @@ -673,6 +679,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: 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', ParseIntPipe) id: number, + @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..f5db3232c0 --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-transfer.dto.ts @@ -0,0 +1,111 @@ +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 --- + +// 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; + + @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: Eip712CaveatDto[]; + 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..6c60a1277c --- /dev/null +++ b/src/subdomains/supporting/realunit/entities/realunit-transfer-request.entity.ts @@ -0,0 +1,74 @@ +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', + PROCESSING = 'Processing', + COMPLETED = 'Completed', + FAILED = 'Failed', +} + +/** + * 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; + } + + processing(): this { + this.status = RealUnitTransferRequestStatus.PROCESSING; + + return this; + } + + complete(txHash: string): this { + this.status = RealUnitTransferRequestStatus.COMPLETED; + this.txHash = txHash; + + return this; + } + + fail(): this { + this.status = RealUnitTransferRequestStatus.FAILED; + + return this; + } + + setTxHash(txHash: string): this { + this.txHash = txHash; + + return this; + } +} diff --git a/src/subdomains/supporting/realunit/realunit-job.service.ts b/src/subdomains/supporting/realunit/realunit-job.service.ts index 8674b8d0fa..ec7da7b76c 100644 --- a/src/subdomains/supporting/realunit/realunit-job.service.ts +++ b/src/subdomains/supporting/realunit/realunit-job.service.ts @@ -75,4 +75,12 @@ export class RealUnitJobService { } } } + + // Resolves RealUnit W2W transfer requests stuck in PROCESSING after a crash/restart between the + // atomic claim and the broadcast/callback in confirmTransfer — see + // RealUnitService.reconcilePendingTransfers for the actual reconciliation logic. + @DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.REALUNIT_TRANSFER_RECONCILIATION, timeout: 1800 }) + async reconcilePendingTransfers(): Promise { + await this.realunitService.reconcilePendingTransfers(); + } } diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index e64f3450c9..a06f66f026 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -24,6 +24,7 @@ import { RealUnitSupportController } from './controllers/realunit-support.contro import { RealUnitController } from './controllers/realunit.controller'; import { AktionariatRegistration } from './entities/aktionariat-registration.entity'; import { RealUnitLegalAcceptance } from './entities/realunit-legal-acceptance.entity'; +import { RealUnitTransferRequest } from './entities/realunit-transfer-request.entity'; import { RealUnitComplianceService } from './realunit-compliance.service'; import { RealUnitDevService } from './realunit-dev.service'; import { RealUnitJobService } from './realunit-job.service'; @@ -32,10 +33,11 @@ import { RealUnitScopeService } from './realunit-scope.service'; import { RealUnitService } from './realunit.service'; import { AktionariatRegistrationRepository } from './repositories/aktionariat-registration.repository'; import { RealUnitLegalAcceptanceRepository } from './repositories/realunit-legal-acceptance.repository'; +import { RealUnitTransferRequestRepository } from './repositories/realunit-transfer-request.repository'; @Module({ imports: [ - TypeOrmModule.forFeature([AktionariatRegistration, RealUnitLegalAcceptance]), + TypeOrmModule.forFeature([AktionariatRegistration, RealUnitLegalAcceptance, RealUnitTransferRequest]), SharedModule, LogModule, PricingModule, @@ -65,6 +67,7 @@ import { RealUnitLegalAcceptanceRepository } from './repositories/realunit-legal RealUnitLegalService, AktionariatRegistrationRepository, RealUnitLegalAcceptanceRepository, + RealUnitTransferRequestRepository, ], exports: [RealUnitService, RealUnitScopeService], }) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 0d11b62586..a70699268b 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -24,7 +24,10 @@ import { import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { Eip7702DelegationService } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; +import { + Eip7702DelegationService, + TransactionRevertedException, +} from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; @@ -71,7 +74,7 @@ import { Department } from 'src/subdomains/supporting/support-issue/enums/depart import { SupportIssueReason, SupportIssueType } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; import { transliterate } from 'transliteration'; -import { EntityManager, FindOptionsRelations, In, Not, Raw } from 'typeorm'; +import { EntityManager, FindOptionsRelations, In, IsNull, LessThan, Not, Raw } from 'typeorm'; import { AssetPricesService } from '../pricing/services/asset-prices.service'; import { PriceCurrency, PriceValidity, PricingService } from '../pricing/services/pricing.service'; import { @@ -111,6 +114,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, @@ -234,6 +244,7 @@ export class RealUnitService { private readonly aktionariatRegistrationRepo: AktionariatRegistrationRepository, private readonly logService: LogService, private readonly supportIssueService: SupportIssueService, + private readonly transferRequestRepo: RealUnitTransferRequestRepository, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -2446,4 +2457,293 @@ export class RealUnitService { if (atIndex <= 0) return '***'; return `${email.charAt(0)}***${email.substring(atIndex)}`; } + + // --- 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 (async — must await; a bare Promise is always truthy) + if (!(await this.hasRegistrationForWallet(userData, user.address))) { + throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_TRANSFER); + } + + // 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_TRANSFER, + ); + } + + // 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. 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 + // 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 (user is eager-loaded by the entity) + const transferRequest = await this.transferRequestRepo.findOne({ + where: { id: requestId }, + }); + if (!transferRequest || transferRequest.user?.id !== userId) { + throw new NotFoundException('Transfer request not found'); + } + + // 2. Idempotency shortcut — MUST run before any balance/gas/delegate checks: a prior attempt may + // already have broadcast (txHash is persisted the instant the tx is signed, before it is sent to the + // node — see Eip7702DelegationService._transferTokenWithUserDelegationInternal), and the sender's + // on-chain balance is no longer guaranteed to cover `amount` again after a successful transfer. + // FAILED requests are excluded so a reverted attempt's txHash is never handed back as a false success. + if (transferRequest.txHash && transferRequest.status !== RealUnitTransferRequestStatus.FAILED) { + return { txHash: transferRequest.txHash }; + } + + const realuAsset = await this.getRealuAsset(); + if (!realuAsset) throw new NotFoundException('REALU asset not found'); + + // 3. 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'); + } + + // 4. Drain preflight: sender must hold enough REALU before we broadcast + const balance = await this.getEvmClient().getTokenBalance(realuAsset, transferRequest.user.address); + if (balance < transferRequest.amount) { + throw new BadRequestException('Insufficient REALU balance for transfer'); + } + + // 5. Abort cleanly if the W2W gas wallet itself is underfunded + await this.assertW2wGasWalletFunded(); + + // 6. Defense-in-depth: delegate must be the W2W gas wallet that will redeem (msg.sender == delegate) + const expectedDelegate = this.getW2wGasWalletAddress(); + if (!Util.equalsIgnoreCase(dto.delegation.delegate, expectedDelegate)) { + throw new BadRequestException('Delegation delegate does not match the W2W gas wallet'); + } + + // 7. Dedicated W2W gas wallet pays gas — never the Sell/OTC relayer + const relayerPrivateKey = this.getW2wGasWalletPrivateKey(); + + // 8. Atomically claim the request (CREATED -> PROCESSING). This serializes concurrent/duplicate + // confirm calls (double-tap, retry-after-timeout): a non-atomic read-then-write on `status` would let + // a second caller pass the guard and relay the same blanket delegation twice. + const res = await this.transferRequestRepo.update( + { id: requestId, status: RealUnitTransferRequestStatus.CREATED }, + { status: RealUnitTransferRequestStatus.PROCESSING }, + ); + if (!res.affected) { + throw new ConflictException('Transfer request is already in progress or completed'); + } + + // 9. Relay the STORED recipient + amount (never values from untrusted client input). Persist the + // txHash the instant it is broadcast — before the receipt wait — so a timeout/crash after broadcast + // never loses track of an already-broadcast transaction. + let broadcastTxHash: string | undefined; + let txHash: string; + try { + txHash = await this.eip7702DelegationService.transferTokenWithUserDelegation( + transferRequest.user.address, + realuAsset, + transferRequest.toAddress, + transferRequest.amount, + dto.delegation, + dto.authorization, + relayerPrivateKey, + async (hash) => { + broadcastTxHash = hash; + await this.transferRequestRepo.update(requestId, { txHash: hash }); + }, + ); + } catch (e) { + if (e instanceof TransactionRevertedException) { + // Broadcast succeeded but the tx reverted on-chain: mark FAILED even though txHash is set, so a + // retry (which excludes FAILED from the idempotency shortcut above) never hands back the reverted + // hash as a success — it re-enters the atomic claim, finds status=FAILED (not CREATED), + // affected=0 -> ConflictException. + await this.transferRequestRepo.update(requestId, { status: RealUnitTransferRequestStatus.FAILED }); + } else if (!broadcastTxHash) { + // Broadcast never happened — nothing is in flight on-chain, safe to mark FAILED. + await this.transferRequestRepo.update(requestId, { status: RealUnitTransferRequestStatus.FAILED }); + } + // else: timeout/unknown error after a successful (non-reverted) broadcast — the tx may still mine. + // Leave the request in PROCESSING (its txHash is already persisted); the reconciliation cron + // (RealUnitService.reconcilePendingTransfers) resolves it on a later run. + throw e; + } + + this.logger.info(`RealUnit W2W transfer confirmed via EIP-7702: ${txHash}`); + + // 10. Mark request as complete + await this.transferRequestRepo.save(transferRequest.complete(txHash)); + + return { txHash }; + } + + /** + * Resolves RealUnitTransferRequest rows stuck in PROCESSING because a crash/restart happened between + * the atomic claim (CREATED -> PROCESSING) and the broadcast/callback in confirmTransfer. Called by + * RealUnitJobService on a cron. Since the txHash is now persisted BEFORE the tx is sent to the node + * (Eip7702DelegationService._transferTokenWithUserDelegationInternal), "PROCESSING without a txHash" + * strictly means "never signed/broadcast" and is safe to mark FAILED once stale. + */ + async reconcilePendingTransfers(): Promise { + const staleThreshold = Util.minutesBefore(5); + const staleRequests = await this.transferRequestRepo.find({ + where: { status: RealUnitTransferRequestStatus.PROCESSING, updated: LessThan(staleThreshold) }, + }); + + for (const request of staleRequests) { + try { + if (!request.txHash) { + // Conditional update: only fail if still PROCESSING with no txHash. Concurrent onBroadcast + // may have set txHash between our find and this write — then affected=0 and we skip. + const res = await this.transferRequestRepo.update( + { id: request.id, status: RealUnitTransferRequestStatus.PROCESSING, txHash: IsNull() }, + { status: RealUnitTransferRequestStatus.FAILED }, + ); + if (res.affected) { + this.logger.info(`RealUnit W2W transfer request ${request.id} reconciled: no broadcast, marked FAILED`); + } + continue; + } + + const receipt = await this.getEvmClient().getTxReceipt(request.txHash); + if (!receipt) continue; // not yet mined / not found — leave PROCESSING for the next run + + if (receipt.status === 1) { + const res = await this.transferRequestRepo.update( + { id: request.id, status: RealUnitTransferRequestStatus.PROCESSING }, + { status: RealUnitTransferRequestStatus.COMPLETED, txHash: request.txHash }, + ); + if (res.affected) { + this.logger.info(`RealUnit W2W transfer request ${request.id} reconciled: confirmed on-chain`); + } + } else { + const res = await this.transferRequestRepo.update( + { id: request.id, status: RealUnitTransferRequestStatus.PROCESSING }, + { status: RealUnitTransferRequestStatus.FAILED }, + ); + if (res.affected) { + this.logger.info( + `RealUnit W2W transfer request ${request.id} reconciled: reverted on-chain, marked FAILED`, + ); + } + } + } catch (e) { + this.logger.error(`Failed to reconcile RealUnit W2W transfer request ${request.id}:`, e); + } + } + } + + 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}`; + } + + // 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 { + const derivedAddress = new ethers.Wallet(this.getW2wGasWalletPrivateKey()).address; + const { w2wGasWalletAddress } = Config.blockchain.realunit; + if (w2wGasWalletAddress && !Util.equalsIgnoreCase(derivedAddress, w2wGasWalletAddress)) { + throw new Error( + 'REALUNIT_W2W_GAS_WALLET_ADDRESS does not match the address derived from REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY', + ); + } + return derivedAddress; + } + + 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); + } +}