Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,8 @@ The RealUnit purchase and sale flows historically lived under `/v1/realunit/brok
| `PUT /v1/realunit/sell/:id/unsigned-transactions` | Reads the on-chain sell price and builds the EIP-7702 batch the user has to sign | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` |
| `PUT /v1/realunit/sell/:id/confirm` | Verifies the user-signed batch against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` |
| `PUT /v1/realunit/sell/:id/broadcast` | Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no `readContract` |
| `PUT /v1/realunit/transfer` | Persists a wallet-to-wallet (W2W) transfer intent and returns the EIP-7702 delegation data to sign. Limit-exempt (on-chain REALU→REALU self-custody movement). | No — prepares the gasless transfer |
| `PUT /v1/realunit/transfer/:id/confirm` | Relays the user-signed EIP-7702 delegation for the stored transfer request; DFX pays gas from the dedicated W2W gas wallet (`REALUNIT_W2W_GAS_WALLET_*`), never the Sell/OTC relayer | No `readContract` — relays the user-authorized ERC20 transfer |

Operational consequences:

Expand Down
30 changes: 30 additions & 0 deletions migration/1780560119568-AddRealUnitTransferRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddRealUnitTransferRequest1780560119568 {
name = 'AddRealUnitTransferRequest1780560119568'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "real_unit_transfer_request" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "uid" character varying(256) NOT NULL, "toAddress" character varying(256) NOT NULL, "amount" double precision NOT NULL, "status" character varying(256) NOT NULL DEFAULT 'Created', "txHash" character varying(256), "userId" integer NOT NULL, CONSTRAINT "UQ_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"`);
}
}
8 changes: 8 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class Configuration {
paymentLinkUidPrefix: 'pl',
paymentLinkPaymentUidPrefix: 'plp',
paymentQuoteUidPrefix: 'plq',
realUnitTransferUidPrefix: 'RT',
};

moderators = {
Expand Down Expand Up @@ -1060,6 +1061,13 @@ export class Configuration {
brokerbotAddress: [Environment.DEV, Environment.LOC].includes(this.environment)
? '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2'
: '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d',
// Dedicated wallet-to-wallet (W2W) transfer gas-funding wallet. Separate from the Sell/OTC
// EIP-7702 relayer (per-chain `…WalletPrivateKey`): DFX pays gas for user-initiated REALU
// W2W transfers from this wallet only. 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('<br>').join('\n'),
w2wGasWalletAddress: process.env.REALUNIT_W2W_GAS_WALLET_ADDRESS,
w2wGasLowBalanceThreshold: +(process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD ?? 0.05), // ETH
bank: {
recipient: process.env.REALUNIT_BANK_RECIPIENT ?? 'RealUnit Schweiz AG',
iban: process.env.REALUNIT_BANK_IBAN ?? 'CH22 0830 7000 5609 4630 9',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jest.mock('../../evm.util', () => ({

import { Test, TestingModule } from '@nestjs/testing';
import * as viem from 'viem';
import * as viemAccounts from 'viem/accounts';
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock';
import { AssetType } from 'src/shared/models/asset/asset.entity';
Expand Down Expand Up @@ -183,6 +184,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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,17 @@ export class Eip7702DelegationService {
/**
* Prepare delegation data for RealUnit (bypasses global disable)
* RealUnit app supports eth_sign, so EIP-7702 works unlike MetaMask
*
* `delegateAddressOverride` (optional) sets the delegation's `delegate` to a caller-supplied
* address instead of the per-chain Sell/OTC relayer. The MetaMask DelegationManager enforces
* `msg.sender === delegation.delegate` in `redeemDelegations`, so the delegate MUST equal the
* address that relays (pays gas) at confirm time. The RealUnit W2W transfer relays from the
* dedicated W2W gas wallet, so it passes that wallet's address here to keep delegate == redeemer.
*/
async prepareDelegationDataForRealUnit(
userAddress: string,
blockchain: Blockchain,
delegateAddressOverride?: string,
): Promise<{
relayerAddress: string;
delegationManagerAddress: string;
Expand All @@ -199,7 +206,7 @@ export class Eip7702DelegationService {
if (!this.isDelegationSupportedForRealUnit(blockchain)) {
throw new Error(`EIP-7702 delegation not supported for RealUnit on ${blockchain}`);
}
return this._prepareDelegationDataInternal(userAddress, blockchain);
return this._prepareDelegationDataInternal(userAddress, blockchain, delegateAddressOverride);
}

/**
Expand All @@ -208,6 +215,7 @@ export class Eip7702DelegationService {
private async _prepareDelegationDataInternal(
userAddress: string,
blockchain: Blockchain,
delegateAddressOverride?: string,
): Promise<{
relayerAddress: string;
delegationManagerAddress: string;
Expand All @@ -228,24 +236,27 @@ 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);
const types = DELEGATION_EIP712_TYPES;

// Delegation message
const message = {
delegate: relayerAccount.address,
delegate: relayerAddress,
delegator: userAddress,
authority: ROOT_AUTHORITY,
caveats: [],
salt: Number(salt), // Convert BigInt to Number for JSON + EIP-712 compatibility
};

return {
relayerAddress: relayerAccount.address,
relayerAddress,
delegationManagerAddress: DELEGATION_MANAGER_ADDRESS,
delegatorAddress: DELEGATOR_ADDRESS,
userNonce,
Expand All @@ -258,6 +269,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,
Expand All @@ -272,8 +287,9 @@ export class Eip7702DelegationService {
signature: string;
},
authorization: Eip7702Authorization,
relayerPrivateKeyOverride?: Hex,
): Promise<string> {
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(
Expand All @@ -283,6 +299,7 @@ export class Eip7702DelegationService {
amount,
signedDelegation,
authorization,
relayerPrivateKeyOverride,
);
}

Expand Down Expand Up @@ -534,6 +551,7 @@ export class Eip7702DelegationService {
signature: string;
},
authorization: Eip7702Authorization,
relayerPrivateKeyOverride?: Hex,
): Promise<string> {
const blockchain = token.blockchain;

Expand Down Expand Up @@ -571,8 +589,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
Expand Down
6 changes: 6 additions & 0 deletions src/subdomains/core/monitoring/monitoring.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -43,6 +46,8 @@ import { SystemStateSnapshotRepository } from './system-state-snapshot.repositor
LightningModule,
FiatPayInModule,
PricingModule,
EthereumModule,
SepoliaModule,
],
providers: [
SystemStateSnapshotRepository,
Expand All @@ -59,6 +64,7 @@ import { SystemStateSnapshotRepository } from './system-state-snapshot.repositor
AmlObserver,
ExchangeObserver,
LiquidityObserver,
RealUnitW2wGasObserver,
],
controllers: [MonitoringController, HealthController],
exports: [MonitoringService],
Expand Down
Loading