Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
44 changes: 23 additions & 21 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions migration/1784600000000-AddRealUnitTransferRequest.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"ts"
],
"rootDir": "src",
"setupFiles": ["<rootDir>/jest-env.setup.ts"],
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1",
"^@dfinity/(ledger-icp|ledger-icrc|utils)$": "<rootDir>/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts",
Expand Down
14 changes: 14 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class Configuration {
paymentLinkUidPrefix: 'pl',
paymentLinkPaymentUidPrefix: 'plp',
paymentQuoteUidPrefix: 'plq',
realUnitTransferUidPrefix: 'RT',
};

moderators = {
Expand Down Expand Up @@ -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('<br>').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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading