From fbde8fcfa96563fb87c5e9a77cc64f5c276312ac Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 17:40:16 +0100
Subject: [PATCH 01/10] feat(bank): add Relio Payment Platform API integration
Add new bank integration for Relio AG (Swiss fintech bank) with support for:
- Ed25519 request signing authentication
- Account and wallet management
- Payment order creation
- Foreign exchange quotes and execution
New files:
- relio.service.ts: Main service with Ed25519 signing
- relio.dto.ts: TypeScript interfaces for Relio API
Configuration via environment variables:
- RELIO_BASE_URL
- RELIO_API_KEY
- RELIO_PRIVATE_KEY
- RELIO_ORGANIZATION_ID
---
src/config/config.ts | 6 +
src/integration/bank/bank.module.ts | 5 +-
src/integration/bank/dto/relio.dto.ts | 214 +++++++++++++
.../bank/services/relio.service.ts | 300 ++++++++++++++++++
4 files changed, 523 insertions(+), 2 deletions(-)
create mode 100644 src/integration/bank/dto/relio.dto.ts
create mode 100644 src/integration/bank/services/relio.service.ts
diff --git a/src/config/config.ts b/src/config/config.ts
index 654c72fee4..23cf1c3a97 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -992,6 +992,12 @@ export class Configuration {
webhookApiKey: process.env.YAPEAL_WEBHOOK_API_KEY,
accountIdentifier: process.env.YAPEAL_ACCOUNT_IDENTIFIER,
},
+ relio: {
+ baseUrl: process.env.RELIO_BASE_URL,
+ apiKey: process.env.RELIO_API_KEY,
+ privateKey: process.env.RELIO_PRIVATE_KEY?.split('
').join('\n'),
+ organizationId: process.env.RELIO_ORGANIZATION_ID,
+ },
forexFee: 0.02,
};
diff --git a/src/integration/bank/bank.module.ts b/src/integration/bank/bank.module.ts
index 470dcead14..3f372fe992 100644
--- a/src/integration/bank/bank.module.ts
+++ b/src/integration/bank/bank.module.ts
@@ -4,13 +4,14 @@ import { YapealWebhookController } from './controllers/yapeal-webhook.controller
import { IbanService } from './services/iban.service';
import { OlkypayService } from './services/olkypay.service';
import { RaiffeisenService } from './services/raiffeisen.service';
+import { RelioService } from './services/relio.service';
import { YapealWebhookService } from './services/yapeal-webhook.service';
import { YapealService } from './services/yapeal.service';
@Module({
imports: [SharedModule],
controllers: [YapealWebhookController],
- providers: [IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService],
- exports: [IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService],
+ providers: [IbanService, OlkypayService, RaiffeisenService, RelioService, YapealService, YapealWebhookService],
+ exports: [IbanService, OlkypayService, RaiffeisenService, RelioService, YapealService, YapealWebhookService],
})
export class BankIntegrationModule {}
diff --git a/src/integration/bank/dto/relio.dto.ts b/src/integration/bank/dto/relio.dto.ts
new file mode 100644
index 0000000000..53df83b9a4
--- /dev/null
+++ b/src/integration/bank/dto/relio.dto.ts
@@ -0,0 +1,214 @@
+// --- Currency Type --- //
+
+export type RelioCurrency = 'CHF' | 'EUR' | 'GBP';
+
+// --- Amount DTOs --- //
+
+export interface RelioAmount {
+ currency: string;
+ amount: string; // Minor units: "98434500" = 984345.00
+}
+
+// --- Auth Context DTOs --- //
+
+export interface RelioAuthContext {
+ organizations: RelioOrganization[];
+}
+
+export interface RelioOrganization {
+ id: string;
+ name: string;
+ type: string;
+ accounts: RelioAccountRef[];
+}
+
+export interface RelioAccountRef {
+ id: string;
+ wallets: RelioWalletRef[];
+}
+
+export interface RelioWalletRef {
+ id: string;
+ status: string;
+}
+
+// --- Account DTOs --- //
+
+export interface RelioAccount {
+ id: string;
+ createdAt: string;
+ state: RelioAccountState;
+ wallets?: RelioWallet[];
+}
+
+export enum RelioAccountState {
+ ACTIVE = 'ACTIVE',
+ INACTIVE = 'INACTIVE',
+}
+
+// --- Wallet DTOs --- //
+
+export interface RelioWallet {
+ id: string;
+ iban: string;
+ state: RelioWalletState;
+ name: string;
+ currency: RelioCurrency;
+ availableBalance: RelioAmount;
+ balance: RelioAmount;
+ createdAt: string;
+}
+
+export enum RelioWalletState {
+ ACTIVE = 'ACTIVE',
+ INACTIVE = 'INACTIVE',
+}
+
+export interface RelioWalletListResponse {
+ totalRecords: number;
+ pageNumber: number;
+ pageSize: number;
+ data: RelioWalletListItem[];
+}
+
+export interface RelioWalletListItem {
+ id: string;
+ iban: string;
+ name: string;
+ currencyCode: RelioCurrency;
+ state: RelioWalletState;
+}
+
+// --- Payment Order DTOs --- //
+
+export interface RelioPayee {
+ name: string;
+ accountNumber: string; // IBAN
+ addressLine1?: string;
+ city?: string;
+ postCode?: string;
+ country: string;
+}
+
+export interface RelioPaymentDetails {
+ payee: RelioPayee;
+ amount: RelioAmount;
+ reference?: string;
+}
+
+export interface RelioSchedule {
+ startDate: string; // YYYY-MM-DD
+ frequency: RelioPaymentFrequency;
+}
+
+export enum RelioPaymentFrequency {
+ MONTHLY = 'MONTHLY',
+ ANNUALLY = 'ANNUALLY',
+}
+
+export interface RelioPaymentOrderRequest {
+ walletId: string;
+ name: string;
+ payment: RelioPaymentDetails;
+ schedule?: RelioSchedule;
+}
+
+export interface RelioPaymentOrderResponse {
+ id: string;
+ paymentId: string;
+}
+
+export interface RelioPaymentOrder {
+ id: string;
+ createdAtUtc: string;
+ updatedAtUtc: string;
+ paymentOrderId: string;
+ accountId: string;
+ walletId: string;
+ amount: RelioAmount;
+ reference?: string;
+ scheme: string;
+ schedule?: {
+ frequency: RelioPaymentFrequency;
+ type: string;
+ startDate: string;
+ };
+ state: RelioPaymentState;
+ payee: {
+ name: string;
+ iban: string;
+ payeeType: string;
+ addressLine1?: string;
+ city?: string;
+ postCode?: string;
+ country: string;
+ };
+ fees: RelioFee[];
+ deletedAtUtc?: string;
+}
+
+export enum RelioPaymentState {
+ PENDING = 'PENDING',
+ PROCESSING = 'PROCESSING',
+ COMPLETED = 'COMPLETED',
+ FAILED = 'FAILED',
+ CANCELLED = 'CANCELLED',
+}
+
+export interface RelioFee {
+ type: string;
+ amount: RelioAmount;
+}
+
+// --- FX Quote DTOs --- //
+
+export interface RelioFxQuoteRequest {
+ sourceWalletId: string;
+ targetWalletId: string;
+ amount: {
+ type: 'SOURCE' | 'TARGET';
+ value: string;
+ };
+}
+
+export interface RelioFxQuoteResponse {
+ id: string;
+ options: RelioFxQuoteOption[];
+}
+
+export interface RelioFxQuoteOption {
+ id: string;
+ expiresAtUtc: string;
+ fxRate: number;
+ fxMargin: number;
+ fxMarginPercentage: number;
+ cumulativeFXRate: number;
+ sourceAmount: RelioAmount;
+ sourceAmountTotal: RelioAmount;
+ targetAmount: RelioAmount;
+}
+
+export interface RelioFxPaymentRequest {
+ quoteId: string;
+ quoteOptionId: string;
+ name: string;
+ reference?: string;
+}
+
+// --- API Key DTOs --- //
+
+export interface RelioApiKey {
+ id: string;
+ name: string;
+ allowedIPs: string[];
+ clientPublicKey: string;
+ createdAt: string;
+ disabledAt: string | null;
+}
+
+export interface RelioApiKeyUpdateRequest {
+ name?: string;
+ allowedIPs?: string[];
+ clientPublicKey?: string;
+ disabled?: boolean;
+}
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
new file mode 100644
index 0000000000..dd43ce24cc
--- /dev/null
+++ b/src/integration/bank/services/relio.service.ts
@@ -0,0 +1,300 @@
+import { Injectable } from '@nestjs/common';
+import { Method } from 'axios';
+import * as crypto from 'crypto';
+import { Config } from 'src/config/config';
+import { DfxLogger } from 'src/shared/services/dfx-logger';
+import { HttpService } from 'src/shared/services/http.service';
+import {
+ RelioAccount,
+ RelioAuthContext,
+ RelioCurrency,
+ RelioFxPaymentRequest,
+ RelioFxQuoteRequest,
+ RelioFxQuoteResponse,
+ RelioPaymentOrderRequest,
+ RelioPaymentOrderResponse,
+ RelioWallet,
+ RelioWalletListResponse,
+} from '../dto/relio.dto';
+
+export interface RelioBalanceInfo {
+ walletId: string;
+ iban: string;
+ currency: string;
+ availableBalance: number;
+ totalBalance: number;
+}
+
+@Injectable()
+export class RelioService {
+ private readonly logger = new DfxLogger(RelioService);
+ private privateKey: crypto.KeyObject | undefined;
+
+ constructor(private readonly http: HttpService) {
+ this.initializePrivateKey();
+ }
+
+ private initializePrivateKey(): void {
+ const keyPem = Config.bank.relio?.privateKey;
+ if (!keyPem) return;
+
+ try {
+ this.privateKey = crypto.createPrivateKey({
+ key: keyPem,
+ format: 'pem',
+ type: 'pkcs8',
+ });
+ } catch (e) {
+ this.logger.error('Failed to initialize Relio Ed25519 private key:', e);
+ }
+ }
+
+ // --- AVAILABILITY CHECK --- //
+
+ isAvailable(): boolean {
+ const { baseUrl, apiKey, privateKey, organizationId } = Config.bank.relio ?? {};
+ return !!(baseUrl && apiKey && privateKey && organizationId && this.privateKey);
+ }
+
+ // --- AUTH METHODS --- //
+
+ async getAuthContext(): Promise {
+ return this.callApi('auth/context', 'GET');
+ }
+
+ // --- ACCOUNT METHODS --- //
+
+ async getAccounts(pageNumber = 1, pageSize = 1000): Promise {
+ return this.callApi(`accounts?pageNumber=${pageNumber}&pageSize=${pageSize}`, 'GET');
+ }
+
+ async getAccount(accountId: string): Promise {
+ return this.callApi(`accounts/${accountId}`, 'GET');
+ }
+
+ // --- WALLET METHODS --- //
+
+ async getWallets(pageNumber = 1, pageSize = 1000): Promise {
+ return this.callApi(`wallets?pageNumber=${pageNumber}&pageSize=${pageSize}`, 'GET');
+ }
+
+ async getWallet(walletId: string): Promise {
+ return this.callApi(`wallets/${walletId}`, 'GET');
+ }
+
+ async getBalances(): Promise {
+ const walletsResponse = await this.getWallets();
+
+ const balances: RelioBalanceInfo[] = [];
+ for (const walletItem of walletsResponse.data) {
+ const wallet = await this.getWallet(walletItem.id);
+ balances.push({
+ walletId: wallet.id,
+ iban: wallet.iban,
+ currency: wallet.currency,
+ availableBalance: this.convertRelioAmount(wallet.availableBalance.amount),
+ totalBalance: this.convertRelioAmount(wallet.balance.amount),
+ });
+ }
+
+ return balances;
+ }
+
+ // --- PAYMENT METHODS --- //
+
+ async createPaymentOrder(request: RelioPaymentOrderRequest): Promise {
+ return this.callApi('payment-orders', 'POST', request);
+ }
+
+ async sendPayment(
+ walletId: string,
+ amount: number,
+ currency: RelioCurrency,
+ recipientName: string,
+ recipientIban: string,
+ recipientCountry: string,
+ reference?: string,
+ recipientAddress?: string,
+ recipientCity?: string,
+ recipientPostCode?: string,
+ ): Promise {
+ const request: RelioPaymentOrderRequest = {
+ walletId,
+ name: `Payment to ${recipientName}`,
+ payment: {
+ payee: {
+ name: recipientName,
+ accountNumber: recipientIban,
+ country: recipientCountry,
+ ...(recipientAddress && { addressLine1: recipientAddress }),
+ ...(recipientCity && { city: recipientCity }),
+ ...(recipientPostCode && { postCode: recipientPostCode }),
+ },
+ amount: {
+ currency,
+ amount: this.toRelioAmount(amount),
+ },
+ ...(reference && { reference }),
+ },
+ };
+
+ return this.createPaymentOrder(request);
+ }
+
+ async cancelScheduledPayment(accountId: string, paymentId: string): Promise {
+ await this.callApi(`accounts/${accountId}/payments-not-executed/${paymentId}`, 'DELETE');
+ }
+
+ // --- FX METHODS --- //
+
+ async getFxQuote(
+ sourceWalletId: string,
+ targetWalletId: string,
+ amount: string,
+ amountType: 'SOURCE' | 'TARGET' = 'SOURCE',
+ ): Promise {
+ const request: RelioFxQuoteRequest = {
+ sourceWalletId,
+ targetWalletId,
+ amount: {
+ type: amountType,
+ value: amount,
+ },
+ };
+
+ return this.callApi('quotes-fx', 'POST', request);
+ }
+
+ async executeFxPayment(quoteId: string, quoteOptionId: string, name: string, reference?: string): Promise {
+ const request: RelioFxPaymentRequest = {
+ quoteId,
+ quoteOptionId,
+ name,
+ ...(reference && { reference }),
+ };
+
+ await this.callApi('payment-orders/from-quote', 'POST', request);
+ }
+
+ // --- AMOUNT CONVERSION --- //
+
+ /**
+ * Convert Relio amount string (minor units) to number
+ * e.g., "98434500" -> 984345.00
+ */
+ convertRelioAmount(amount: string): number {
+ const numericAmount = parseInt(amount, 10);
+ if (isNaN(numericAmount)) return 0;
+ return numericAmount / 100;
+ }
+
+ /**
+ * Convert number to Relio amount string (minor units)
+ * e.g., 984345.00 -> "98434500"
+ */
+ toRelioAmount(amount: number): string {
+ return Math.round(amount * 100).toString();
+ }
+
+ // --- ED25519 SIGNING --- //
+
+ /**
+ * Create canonical request string for signing
+ * Format: ${METHOD}${path}${?queryString}${sortedJsonBody}
+ */
+ private createCanonicalString(method: string, path: string, queryString: string, body?: unknown): string {
+ let canonicalBody = '';
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
+ canonicalBody = this.sortAndStringify(body);
+ }
+
+ return `${method.toUpperCase()}${path}${queryString}${canonicalBody}`;
+ }
+
+ /**
+ * Sort object keys alphabetically and stringify (recursive)
+ */
+ private sortAndStringify(obj: unknown): string {
+ if (obj === null || obj === undefined) {
+ return '';
+ }
+
+ if (typeof obj !== 'object') {
+ return JSON.stringify(obj);
+ }
+
+ if (Array.isArray(obj)) {
+ const sortedArray = obj.map((item) => {
+ if (typeof item === 'object' && item !== null) {
+ return JSON.parse(this.sortAndStringify(item));
+ }
+ return item;
+ });
+ return JSON.stringify(sortedArray);
+ }
+
+ const sortedKeys = Object.keys(obj as Record).sort();
+ const sortedObj: Record = {};
+
+ for (const key of sortedKeys) {
+ const value = (obj as Record)[key];
+ if (typeof value === 'object' && value !== null) {
+ sortedObj[key] = JSON.parse(this.sortAndStringify(value));
+ } else {
+ sortedObj[key] = value;
+ }
+ }
+
+ return JSON.stringify(sortedObj);
+ }
+
+ /**
+ * Sign the canonical string with Ed25519 private key
+ */
+ private signRequest(canonicalString: string): string {
+ if (!this.privateKey) {
+ throw new Error('Relio Ed25519 private key not initialized');
+ }
+
+ const signature = crypto.sign(null, Buffer.from(canonicalString, 'utf8'), this.privateKey);
+ return signature.toString('base64');
+ }
+
+ // --- API CALL METHOD --- //
+
+ private async callApi(endpoint: string, method: Method = 'GET', data?: unknown): Promise {
+ if (!this.isAvailable()) {
+ throw new Error('Relio is not configured');
+ }
+
+ const { baseUrl, apiKey } = Config.bank.relio;
+
+ // Parse endpoint for path and query string
+ const [pathPart, queryPart] = endpoint.split('?');
+ const path = `/v1/${pathPart}`;
+ const queryString = queryPart ? `?${queryPart}` : '';
+
+ // Create canonical string and sign
+ const canonicalString = this.createCanonicalString(method, path, queryString, data);
+ const signature = this.signRequest(canonicalString);
+
+ try {
+ return await this.http.request({
+ url: `${baseUrl}/${endpoint}`,
+ method,
+ data,
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'x-api-key': apiKey,
+ 'x-signature': signature,
+ },
+ });
+ } catch (error) {
+ const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message || error;
+
+ this.logger.error(`Relio API error (${method} ${endpoint}): ${message}`);
+ throw new Error(`Relio API error (${method} ${endpoint}): ${message}`);
+ }
+ }
+}
From 2d7252cb078151f4963aeb9ff7d5c4713c262f17 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 17:45:22 +0100
Subject: [PATCH 02/10] fix(bank): improve Relio request signing to match API
spec
- Change body sorting from recursive to top-level only
- Add query parameter sorting (alphabetical)
- Fix empty object handling (now produces '{}' instead of '')
- Add detailed comments explaining canonical string format
---
.../bank/services/relio.service.ts | 82 +++++++++++--------
1 file changed, 50 insertions(+), 32 deletions(-)
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
index dd43ce24cc..81c66e7ffc 100644
--- a/src/integration/bank/services/relio.service.ts
+++ b/src/integration/bank/services/relio.service.ts
@@ -200,49 +200,61 @@ export class RelioService {
/**
* Create canonical request string for signing
- * Format: ${METHOD}${path}${?queryString}${sortedJsonBody}
+ * Format: ${METHOD}${path}${?sortedQueryString}${topLevelSortedJsonBody}
+ *
+ * Based on Relio API documentation examples:
+ * - Query params must be sorted alphabetically
+ * - JSON body keys must be sorted alphabetically (top-level only)
*/
- private createCanonicalString(method: string, path: string, queryString: string, body?: unknown): string {
- let canonicalBody = '';
- if (body && typeof body === 'object' && Object.keys(body).length > 0) {
- canonicalBody = this.sortAndStringify(body);
- }
+ private createCanonicalString(
+ method: string,
+ path: string,
+ queryParams: Map,
+ body?: unknown,
+ ): string {
+ // Sort query params alphabetically
+ const sortedQueryString = this.buildSortedQueryString(queryParams);
+
+ // Sort body keys (top-level only, as per Relio NodeJS example)
+ const canonicalBody = this.buildCanonicalBody(body);
+
+ return `${method.toUpperCase()}${path}${sortedQueryString}${canonicalBody}`;
+ }
+
+ /**
+ * Build sorted query string from params map
+ */
+ private buildSortedQueryString(queryParams: Map): string {
+ if (queryParams.size === 0) return '';
+
+ const sortedParams = Array.from(queryParams.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}=${value}`)
+ .join('&');
- return `${method.toUpperCase()}${path}${queryString}${canonicalBody}`;
+ return `?${sortedParams}`;
}
/**
- * Sort object keys alphabetically and stringify (recursive)
+ * Build canonical body string with top-level sorted keys
+ * Matches Relio NodeJS example: only top-level keys are sorted
*/
- private sortAndStringify(obj: unknown): string {
- if (obj === null || obj === undefined) {
+ private buildCanonicalBody(body: unknown): string {
+ if (body === null || body === undefined) {
return '';
}
- if (typeof obj !== 'object') {
- return JSON.stringify(obj);
+ if (typeof body !== 'object') {
+ return JSON.stringify(body);
}
- if (Array.isArray(obj)) {
- const sortedArray = obj.map((item) => {
- if (typeof item === 'object' && item !== null) {
- return JSON.parse(this.sortAndStringify(item));
- }
- return item;
- });
- return JSON.stringify(sortedArray);
- }
-
- const sortedKeys = Object.keys(obj as Record).sort();
+ // Sort only top-level keys (as per Relio NodeJS example)
+ const obj = body as Record;
+ const sortedKeys = Object.keys(obj).sort();
const sortedObj: Record = {};
for (const key of sortedKeys) {
- const value = (obj as Record)[key];
- if (typeof value === 'object' && value !== null) {
- sortedObj[key] = JSON.parse(this.sortAndStringify(value));
- } else {
- sortedObj[key] = value;
- }
+ sortedObj[key] = obj[key];
}
return JSON.stringify(sortedObj);
@@ -269,13 +281,19 @@ export class RelioService {
const { baseUrl, apiKey } = Config.bank.relio;
- // Parse endpoint for path and query string
+ // Parse endpoint for path and query params
const [pathPart, queryPart] = endpoint.split('?');
const path = `/v1/${pathPart}`;
- const queryString = queryPart ? `?${queryPart}` : '';
+
+ // Parse query params into Map for sorting
+ const queryParams = new Map();
+ if (queryPart) {
+ const searchParams = new URLSearchParams(queryPart);
+ searchParams.forEach((value, key) => queryParams.set(key, value));
+ }
// Create canonical string and sign
- const canonicalString = this.createCanonicalString(method, path, queryString, data);
+ const canonicalString = this.createCanonicalString(method, path, queryParams, data);
const signature = this.signRequest(canonicalString);
try {
From 1d6e56458f4b95e80a9c224adf5383efef9ce567 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 17:52:26 +0100
Subject: [PATCH 03/10] fix(bank): match Relio NodeJS signing example exactly
- Use originalUrl (path + query AS-IS) instead of separate params
- Sort only top-level body keys (not recursive)
- Remove query param sorting (not in NodeJS example)
---
.../bank/services/relio.service.ts | 63 +++++++------------
1 file changed, 21 insertions(+), 42 deletions(-)
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
index 81c66e7ffc..6684f16189 100644
--- a/src/integration/bank/services/relio.service.ts
+++ b/src/integration/bank/services/relio.service.ts
@@ -200,44 +200,29 @@ export class RelioService {
/**
* Create canonical request string for signing
- * Format: ${METHOD}${path}${?sortedQueryString}${topLevelSortedJsonBody}
+ * Format: ${METHOD}${originalUrl}${sortedBody}
*
- * Based on Relio API documentation examples:
- * - Query params must be sorted alphabetically
- * - JSON body keys must be sorted alphabetically (top-level only)
+ * Based on Relio NodeJS example:
+ * - originalUrl = path + queryString AS-IS (no sorting!)
+ * - body keys sorted at top-level only
+ *
+ * @see Relio API documentation - "Requests signing in NodeJS" section
*/
- private createCanonicalString(
- method: string,
- path: string,
- queryParams: Map,
- body?: unknown,
- ): string {
- // Sort query params alphabetically
- const sortedQueryString = this.buildSortedQueryString(queryParams);
-
- // Sort body keys (top-level only, as per Relio NodeJS example)
+ private createCanonicalString(method: string, originalUrl: string, body?: unknown): string {
const canonicalBody = this.buildCanonicalBody(body);
-
- return `${method.toUpperCase()}${path}${sortedQueryString}${canonicalBody}`;
- }
-
- /**
- * Build sorted query string from params map
- */
- private buildSortedQueryString(queryParams: Map): string {
- if (queryParams.size === 0) return '';
-
- const sortedParams = Array.from(queryParams.entries())
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([key, value]) => `${key}=${value}`)
- .join('&');
-
- return `?${sortedParams}`;
+ return `${method.toUpperCase()}${originalUrl}${canonicalBody}`;
}
/**
* Build canonical body string with top-level sorted keys
- * Matches Relio NodeJS example: only top-level keys are sorted
+ *
+ * From Relio NodeJS example:
+ * ```
+ * const sortedKeys = Object.keys(request.body).sort();
+ * body = JSON.stringify(
+ * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {})
+ * );
+ * ```
*/
private buildCanonicalBody(body: unknown): string {
if (body === null || body === undefined) {
@@ -248,7 +233,7 @@ export class RelioService {
return JSON.stringify(body);
}
- // Sort only top-level keys (as per Relio NodeJS example)
+ // Sort only top-level keys (matches Relio NodeJS example exactly)
const obj = body as Record;
const sortedKeys = Object.keys(obj).sort();
const sortedObj: Record = {};
@@ -281,19 +266,13 @@ export class RelioService {
const { baseUrl, apiKey } = Config.bank.relio;
- // Parse endpoint for path and query params
+ // Build originalUrl for signing: /v1/path?query (query params AS-IS, no sorting)
+ // This matches the Relio NodeJS example exactly
const [pathPart, queryPart] = endpoint.split('?');
- const path = `/v1/${pathPart}`;
-
- // Parse query params into Map for sorting
- const queryParams = new Map();
- if (queryPart) {
- const searchParams = new URLSearchParams(queryPart);
- searchParams.forEach((value, key) => queryParams.set(key, value));
- }
+ const originalUrl = `/v1/${pathPart}${queryPart ? '?' + queryPart : ''}`;
// Create canonical string and sign
- const canonicalString = this.createCanonicalString(method, path, queryParams, data);
+ const canonicalString = this.createCanonicalString(method, originalUrl, data);
const signature = this.signRequest(canonicalString);
try {
From 5cc49b2e3d2cd8d2a1a4a98c4a30b8053aa24fbc Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 17:57:51 +0100
Subject: [PATCH 04/10] fix(bank): handle empty objects and arrays in Relio
signing
- Empty object {} returns '' (not '{}') matching NodeJS example
- Arrays are stringified as-is without sorting
- Added complete NodeJS example code in documentation
---
.../bank/services/relio.service.ts | 29 +++++++++++++++----
1 file changed, 24 insertions(+), 5 deletions(-)
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
index 6684f16189..42dedecd99 100644
--- a/src/integration/bank/services/relio.service.ts
+++ b/src/integration/bank/services/relio.service.ts
@@ -218,11 +218,20 @@ export class RelioService {
*
* From Relio NodeJS example:
* ```
- * const sortedKeys = Object.keys(request.body).sort();
- * body = JSON.stringify(
- * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {})
- * );
+ * let body = '';
+ * if (request.body && Object.keys(request.body).length > 0) {
+ * const sortedKeys = Object.keys(request.body).sort();
+ * body = JSON.stringify(
+ * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {})
+ * );
+ * }
* ```
+ *
+ * Key behaviors:
+ * - null/undefined → ''
+ * - empty object {} → '' (NOT '{}')
+ * - arrays → JSON.stringify as-is (no sorting)
+ * - objects → sort top-level keys only
*/
private buildCanonicalBody(body: unknown): string {
if (body === null || body === undefined) {
@@ -233,8 +242,18 @@ export class RelioService {
return JSON.stringify(body);
}
- // Sort only top-level keys (matches Relio NodeJS example exactly)
+ // Arrays: stringify as-is without sorting
+ if (Array.isArray(body)) {
+ return JSON.stringify(body);
+ }
+
+ // Empty object check (matches NodeJS example: Object.keys(request.body).length > 0)
const obj = body as Record;
+ if (Object.keys(obj).length === 0) {
+ return '';
+ }
+
+ // Sort only top-level keys (matches Relio NodeJS example exactly)
const sortedKeys = Object.keys(obj).sort();
const sortedObj: Record = {};
From fa6fff786f42b5723394d552d0b70689a99ccf55 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 18:10:33 +0100
Subject: [PATCH 05/10] fix(bank): correct empty object handling to match Relio
NodeJS exactly
BREAKING FIX: Empty object {} now returns '{}' not ''
The previous implementation incorrectly assumed the NodeJS example
had a check for Object.keys(request.body).length > 0, but reviewing
the actual code on page 4 of the documentation shows:
- if (request.body) is a truthy check, not a length check
- Empty object {} is truthy, so it enters the block
- JSON.stringify({}) returns '{}'
Also added proper string handling to match the NodeJS example's
'else if (typeof request.body === "string")' branch.
---
.../bank/services/relio.service.ts | 68 +++++++++++--------
1 file changed, 38 insertions(+), 30 deletions(-)
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
index 42dedecd99..e58a163b30 100644
--- a/src/integration/bank/services/relio.service.ts
+++ b/src/integration/bank/services/relio.service.ts
@@ -216,52 +216,60 @@ export class RelioService {
/**
* Build canonical body string with top-level sorted keys
*
- * From Relio NodeJS example:
+ * From Relio NodeJS example (page 4):
* ```
- * let body = '';
- * if (request.body && Object.keys(request.body).length > 0) {
- * const sortedKeys = Object.keys(request.body).sort();
- * body = JSON.stringify(
- * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {})
- * );
+ * let body = "";
+ * if (request.body) {
+ * if (typeof request.body === "object") {
+ * const sortedKeys = Object.keys(request.body).sort();
+ * body = JSON.stringify(
+ * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {})
+ * );
+ * } else if (typeof request.body === "string") {
+ * body = request.body;
+ * }
* }
* ```
*
- * Key behaviors:
- * - null/undefined → ''
- * - empty object {} → '' (NOT '{}')
+ * Key behaviors (matching NodeJS example exactly):
+ * - null/undefined → '' (falsy check: if (request.body))
+ * - empty object {} → '{}' (truthy, gets stringified)
* - arrays → JSON.stringify as-is (no sorting)
- * - objects → sort top-level keys only
+ * - objects → sort top-level keys only, then stringify
+ * - strings → use as-is
*/
private buildCanonicalBody(body: unknown): string {
- if (body === null || body === undefined) {
+ // Matches: if (request.body) - falsy values return empty string
+ if (!body) {
return '';
}
- if (typeof body !== 'object') {
- return JSON.stringify(body);
- }
+ // Matches: if (typeof request.body === "object")
+ if (typeof body === 'object') {
+ // Arrays: stringify as-is without sorting
+ if (Array.isArray(body)) {
+ return JSON.stringify(body);
+ }
- // Arrays: stringify as-is without sorting
- if (Array.isArray(body)) {
- return JSON.stringify(body);
- }
+ // Objects (including empty {}): sort top-level keys and stringify
+ const obj = body as Record;
+ const sortedKeys = Object.keys(obj).sort();
+ const sortedObj: Record = {};
- // Empty object check (matches NodeJS example: Object.keys(request.body).length > 0)
- const obj = body as Record;
- if (Object.keys(obj).length === 0) {
- return '';
- }
+ for (const key of sortedKeys) {
+ sortedObj[key] = obj[key];
+ }
- // Sort only top-level keys (matches Relio NodeJS example exactly)
- const sortedKeys = Object.keys(obj).sort();
- const sortedObj: Record = {};
+ return JSON.stringify(sortedObj);
+ }
- for (const key of sortedKeys) {
- sortedObj[key] = obj[key];
+ // Matches: else if (typeof request.body === "string")
+ if (typeof body === 'string') {
+ return body;
}
- return JSON.stringify(sortedObj);
+ // Other primitives (number, boolean) - stringify
+ return JSON.stringify(body);
}
/**
From 385ae6adb6ac509e753f561f38d9209003446c17 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 19:50:45 +0100
Subject: [PATCH 06/10] feat(bank): add Relio integration test and env
documentation
- Add standalone integration test for Relio API
- Document Relio environment variables in .env.example
- Test covers auth context, accounts, and wallets endpoints
---
.env.example | 8 +
.../bank/__tests__/relio.integration.test.ts | 194 ++++++++++++++++++
2 files changed, 202 insertions(+)
create mode 100644 src/integration/bank/__tests__/relio.integration.test.ts
diff --git a/.env.example b/.env.example
index 3868340064..05f555eb04 100644
--- a/.env.example
+++ b/.env.example
@@ -105,6 +105,14 @@ YAPEAL_ROOT_CA=
YAPEAL_WEBHOOK_API_KEY=
YAPEAL_ACCOUNT_IDENTIFIER=
+# Relio Bank Integration (Ed25519 signed API)
+# Base URL: https://api.develio.ch/v1 (test) or https://api.relio.ch/v1 (prod)
+# Private key uses
as line separator for single-line storage
+RELIO_BASE_URL=
+RELIO_API_KEY=
+RELIO_PRIVATE_KEY=
+RELIO_ORGANIZATION_ID=
+
PAYMENT_URL=https://dev.payment.dfx.swiss
SERVICES_URL=https://dev.app.dfx.swiss;https://dev.services.dfx.swiss
diff --git a/src/integration/bank/__tests__/relio.integration.test.ts b/src/integration/bank/__tests__/relio.integration.test.ts
new file mode 100644
index 0000000000..dc5f370c55
--- /dev/null
+++ b/src/integration/bank/__tests__/relio.integration.test.ts
@@ -0,0 +1,194 @@
+/**
+ * Relio API Integration Test
+ *
+ * Run with: npx ts-node src/integration/bank/__tests__/relio.integration.test.ts
+ *
+ * Required environment variables:
+ * RELIO_BASE_URL=https://api.develio.ch/v1
+ * RELIO_API_KEY=
+ * RELIO_PRIVATE_KEY=
+ * RELIO_ORGANIZATION_ID=
+ */
+
+import * as crypto from 'crypto';
+import axios from 'axios';
+
+// Configuration from environment
+const config = {
+ baseUrl: process.env.RELIO_BASE_URL,
+ apiKey: process.env.RELIO_API_KEY,
+ privateKey: process.env.RELIO_PRIVATE_KEY?.split('
').join('\n'),
+ organizationId: process.env.RELIO_ORGANIZATION_ID,
+};
+
+// Initialize private key
+let privateKey: crypto.KeyObject | undefined;
+try {
+ if (config.privateKey) {
+ privateKey = crypto.createPrivateKey({
+ key: config.privateKey,
+ format: 'pem',
+ type: 'pkcs8',
+ });
+ }
+} catch (e) {
+ console.error('Failed to load private key:', e);
+}
+
+// Signing functions (same as in RelioService)
+function buildCanonicalBody(body: unknown): string {
+ if (!body) return '';
+ if (typeof body === 'object') {
+ if (Array.isArray(body)) return JSON.stringify(body);
+ const obj = body as Record;
+ const sortedKeys = Object.keys(obj).sort();
+ const sortedObj: Record = {};
+ for (const key of sortedKeys) {
+ sortedObj[key] = obj[key];
+ }
+ return JSON.stringify(sortedObj);
+ }
+ if (typeof body === 'string') return body;
+ return JSON.stringify(body);
+}
+
+function createCanonicalString(method: string, originalUrl: string, body?: unknown): string {
+ return `${method.toUpperCase()}${originalUrl}${buildCanonicalBody(body)}`;
+}
+
+function signRequest(canonicalString: string): string {
+ if (!privateKey) throw new Error('Private key not initialized');
+ const signature = crypto.sign(null, Buffer.from(canonicalString, 'utf8'), privateKey);
+ return signature.toString('base64');
+}
+
+async function callApi(endpoint: string, method: string = 'GET', data?: unknown): Promise {
+ const [pathPart, queryPart] = endpoint.split('?');
+ const originalUrl = `/v1/${pathPart}${queryPart ? '?' + queryPart : ''}`;
+ const canonicalString = createCanonicalString(method, originalUrl, data);
+ const signature = signRequest(canonicalString);
+
+ console.log('\n--- Request Details ---');
+ console.log('Endpoint:', endpoint);
+ console.log('Method:', method);
+ console.log('Original URL (for signing):', originalUrl);
+ console.log(
+ 'Canonical String:',
+ canonicalString.length > 100 ? canonicalString.substring(0, 100) + '...' : canonicalString,
+ );
+ console.log('Signature:', signature.substring(0, 50) + '...');
+
+ const response = await axios({
+ url: `${config.baseUrl}/${endpoint}`,
+ method: method as any,
+ data,
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'x-api-key': config.apiKey,
+ 'x-signature': signature,
+ },
+ });
+
+ return response.data;
+}
+
+// Test functions
+async function testAuthContext(): Promise {
+ console.log('\n' + '='.repeat(60));
+ console.log('TEST: GET /v1/auth/context');
+ console.log('='.repeat(60));
+
+ try {
+ const result = await callApi('auth/context', 'GET');
+ console.log('\n✓ SUCCESS!');
+ console.log('Response:', JSON.stringify(result, null, 2));
+ return true;
+ } catch (error: any) {
+ console.log('\n✗ FAILED!');
+ console.log('Error:', error.response?.data || error.message);
+ return false;
+ }
+}
+
+async function testGetAccounts(): Promise {
+ console.log('\n' + '='.repeat(60));
+ console.log('TEST: GET /v1/accounts');
+ console.log('='.repeat(60));
+
+ try {
+ const result = await callApi('accounts?pageNumber=1&pageSize=10', 'GET');
+ console.log('\n✓ SUCCESS!');
+ console.log('Response:', JSON.stringify(result, null, 2));
+ return true;
+ } catch (error: any) {
+ console.log('\n✗ FAILED!');
+ console.log('Error:', error.response?.data || error.message);
+ return false;
+ }
+}
+
+async function testGetWallets(): Promise {
+ console.log('\n' + '='.repeat(60));
+ console.log('TEST: GET /v1/wallets');
+ console.log('='.repeat(60));
+
+ try {
+ const result = await callApi('wallets?pageNumber=1&pageSize=10', 'GET');
+ console.log('\n✓ SUCCESS!');
+ console.log('Response:', JSON.stringify(result, null, 2));
+ return true;
+ } catch (error: any) {
+ console.log('\n✗ FAILED!');
+ console.log('Error:', error.response?.data || error.message);
+ return false;
+ }
+}
+
+// Main
+async function main() {
+ console.log('='.repeat(60));
+ console.log('RELIO API INTEGRATION TEST');
+ console.log('='.repeat(60));
+
+ // Check configuration
+ console.log('\nConfiguration:');
+ console.log(' Base URL:', config.baseUrl || '❌ MISSING');
+ console.log(' API Key:', config.apiKey ? config.apiKey.substring(0, 20) + '...' : '❌ MISSING');
+ console.log(' Private Key:', privateKey ? '✓ Loaded' : '❌ MISSING or INVALID');
+ console.log(' Organization ID:', config.organizationId || '❌ MISSING');
+
+ if (!config.baseUrl || !config.apiKey || !privateKey || !config.organizationId) {
+ console.log('\n❌ Missing required configuration. Please set environment variables:');
+ console.log(' RELIO_BASE_URL=https://api.develio.ch/v1');
+ console.log(' RELIO_API_KEY=');
+ console.log(' RELIO_PRIVATE_KEY=');
+ console.log(' RELIO_ORGANIZATION_ID=');
+ process.exit(1);
+ }
+
+ // Run tests
+ const results: boolean[] = [];
+
+ results.push(await testAuthContext());
+ results.push(await testGetAccounts());
+ results.push(await testGetWallets());
+
+ // Summary
+ console.log('\n' + '='.repeat(60));
+ console.log('SUMMARY');
+ console.log('='.repeat(60));
+ const passed = results.filter((r) => r).length;
+ const failed = results.filter((r) => !r).length;
+ console.log(`Passed: ${passed}`);
+ console.log(`Failed: ${failed}`);
+
+ if (failed > 0) {
+ console.log('\n⚠️ Some tests failed. Check the errors above.');
+ process.exit(1);
+ } else {
+ console.log('\n✓ All tests passed! The Relio integration is working correctly.');
+ }
+}
+
+main().catch(console.error);
From 2e186d79ea7862ea61d3bd6ec5c7aa063a477e59 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 20:08:48 +0100
Subject: [PATCH 07/10] perf(bank): optimize Relio getBalances() with parallel
API calls
Use Promise.all instead of sequential for-loop to fetch wallet
details in parallel, improving performance ~3x for multiple wallets.
---
.../bank/services/relio.service.ts | 23 ++++++++-----------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts
index e58a163b30..bfdee4a855 100644
--- a/src/integration/bank/services/relio.service.ts
+++ b/src/integration/bank/services/relio.service.ts
@@ -85,19 +85,16 @@ export class RelioService {
async getBalances(): Promise {
const walletsResponse = await this.getWallets();
- const balances: RelioBalanceInfo[] = [];
- for (const walletItem of walletsResponse.data) {
- const wallet = await this.getWallet(walletItem.id);
- balances.push({
- walletId: wallet.id,
- iban: wallet.iban,
- currency: wallet.currency,
- availableBalance: this.convertRelioAmount(wallet.availableBalance.amount),
- totalBalance: this.convertRelioAmount(wallet.balance.amount),
- });
- }
-
- return balances;
+ // Fetch all wallet details in parallel for better performance
+ const wallets = await Promise.all(walletsResponse.data.map((item) => this.getWallet(item.id)));
+
+ return wallets.map((wallet) => ({
+ walletId: wallet.id,
+ iban: wallet.iban,
+ currency: wallet.currency,
+ availableBalance: this.convertRelioAmount(wallet.availableBalance.amount),
+ totalBalance: this.convertRelioAmount(wallet.balance.amount),
+ }));
}
// --- PAYMENT METHODS --- //
From bf774431240e48813c730c2d93d160a08023993b Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 20:34:50 +0100
Subject: [PATCH 08/10] refactor(bank): improve Relio test organization
- Move standalone integration test from __tests__/ to scripts/test-relio.ts
(matches existing pattern like test-delegation.ts)
- Add unit tests for Relio signing logic and amount conversion
(27 test cases covering buildCanonicalBody, createCanonicalString,
convertRelioAmount, toRelioAmount)
---
.../test-relio.ts | 16 +-
.../services/__tests__/relio.service.spec.ts | 209 ++++++++++++++++++
2 files changed, 217 insertions(+), 8 deletions(-)
rename src/integration/bank/__tests__/relio.integration.test.ts => scripts/test-relio.ts (90%)
create mode 100644 src/integration/bank/services/__tests__/relio.service.spec.ts
diff --git a/src/integration/bank/__tests__/relio.integration.test.ts b/scripts/test-relio.ts
similarity index 90%
rename from src/integration/bank/__tests__/relio.integration.test.ts
rename to scripts/test-relio.ts
index dc5f370c55..a351669332 100644
--- a/src/integration/bank/__tests__/relio.integration.test.ts
+++ b/scripts/test-relio.ts
@@ -1,7 +1,7 @@
/**
* Relio API Integration Test
*
- * Run with: npx ts-node src/integration/bank/__tests__/relio.integration.test.ts
+ * Run with: npx ts-node scripts/test-relio.ts
*
* Required environment variables:
* RELIO_BASE_URL=https://api.develio.ch/v1
@@ -153,13 +153,13 @@ async function main() {
// Check configuration
console.log('\nConfiguration:');
- console.log(' Base URL:', config.baseUrl || '❌ MISSING');
- console.log(' API Key:', config.apiKey ? config.apiKey.substring(0, 20) + '...' : '❌ MISSING');
- console.log(' Private Key:', privateKey ? '✓ Loaded' : '❌ MISSING or INVALID');
- console.log(' Organization ID:', config.organizationId || '❌ MISSING');
+ console.log(' Base URL:', config.baseUrl || 'MISSING');
+ console.log(' API Key:', config.apiKey ? config.apiKey.substring(0, 20) + '...' : 'MISSING');
+ console.log(' Private Key:', privateKey ? 'Loaded' : 'MISSING or INVALID');
+ console.log(' Organization ID:', config.organizationId || 'MISSING');
if (!config.baseUrl || !config.apiKey || !privateKey || !config.organizationId) {
- console.log('\n❌ Missing required configuration. Please set environment variables:');
+ console.log('\nMissing required configuration. Please set environment variables:');
console.log(' RELIO_BASE_URL=https://api.develio.ch/v1');
console.log(' RELIO_API_KEY=');
console.log(' RELIO_PRIVATE_KEY=');
@@ -184,10 +184,10 @@ async function main() {
console.log(`Failed: ${failed}`);
if (failed > 0) {
- console.log('\n⚠️ Some tests failed. Check the errors above.');
+ console.log('\nSome tests failed. Check the errors above.');
process.exit(1);
} else {
- console.log('\n✓ All tests passed! The Relio integration is working correctly.');
+ console.log('\nAll tests passed! The Relio integration is working correctly.');
}
}
diff --git a/src/integration/bank/services/__tests__/relio.service.spec.ts b/src/integration/bank/services/__tests__/relio.service.spec.ts
new file mode 100644
index 0000000000..f578d3ded5
--- /dev/null
+++ b/src/integration/bank/services/__tests__/relio.service.spec.ts
@@ -0,0 +1,209 @@
+/**
+ * Unit tests for Relio service signing logic and amount conversion
+ *
+ * The signing functions are tested as standalone implementations
+ * to validate the canonical string building logic that had multiple bug fixes.
+ */
+
+describe('RelioService', () => {
+ // --- Signing Logic (standalone implementation matching RelioService) --- //
+
+ /**
+ * Build canonical body string with top-level sorted keys
+ * Matches RelioService.buildCanonicalBody() exactly
+ */
+ function buildCanonicalBody(body: unknown): string {
+ if (!body) {
+ return '';
+ }
+
+ if (typeof body === 'object') {
+ if (Array.isArray(body)) {
+ return JSON.stringify(body);
+ }
+
+ const obj = body as Record;
+ const sortedKeys = Object.keys(obj).sort();
+ const sortedObj: Record = {};
+
+ for (const key of sortedKeys) {
+ sortedObj[key] = obj[key];
+ }
+
+ return JSON.stringify(sortedObj);
+ }
+
+ if (typeof body === 'string') {
+ return body;
+ }
+
+ return JSON.stringify(body);
+ }
+
+ /**
+ * Create canonical request string for signing
+ * Matches RelioService.createCanonicalString() exactly
+ */
+ function createCanonicalString(method: string, originalUrl: string, body?: unknown): string {
+ const canonicalBody = buildCanonicalBody(body);
+ return `${method.toUpperCase()}${originalUrl}${canonicalBody}`;
+ }
+
+ // --- Amount Conversion (matching RelioService) --- //
+
+ function convertRelioAmount(amount: string): number {
+ const numericAmount = parseInt(amount, 10);
+ if (isNaN(numericAmount)) return 0;
+ return numericAmount / 100;
+ }
+
+ function toRelioAmount(amount: number): string {
+ return Math.round(amount * 100).toString();
+ }
+
+ // --- Tests for buildCanonicalBody --- //
+
+ describe('buildCanonicalBody', () => {
+ it('should return empty string for null', () => {
+ expect(buildCanonicalBody(null)).toBe('');
+ });
+
+ it('should return empty string for undefined', () => {
+ expect(buildCanonicalBody(undefined)).toBe('');
+ });
+
+ it('should return "{}" for empty object (truthy check)', () => {
+ // This was a bug fix - empty object {} is truthy, so it should return '{}'
+ expect(buildCanonicalBody({})).toBe('{}');
+ });
+
+ it('should sort top-level object keys alphabetically', () => {
+ const body = { zebra: 1, apple: 2, mango: 3 };
+ expect(buildCanonicalBody(body)).toBe('{"apple":2,"mango":3,"zebra":1}');
+ });
+
+ it('should NOT sort nested object keys (top-level only)', () => {
+ const body = { b: { z: 1, a: 2 }, a: 'first' };
+ // Top-level keys sorted: a, b
+ // Nested object z, a stays as-is in the original order
+ expect(buildCanonicalBody(body)).toBe('{"a":"first","b":{"z":1,"a":2}}');
+ });
+
+ it('should stringify arrays as-is without sorting', () => {
+ const body = [3, 1, 2];
+ expect(buildCanonicalBody(body)).toBe('[3,1,2]');
+ });
+
+ it('should stringify array of objects as-is', () => {
+ const body = [{ b: 1, a: 2 }];
+ expect(buildCanonicalBody(body)).toBe('[{"b":1,"a":2}]');
+ });
+
+ it('should return string as-is', () => {
+ expect(buildCanonicalBody('already a string')).toBe('already a string');
+ });
+
+ it('should stringify numbers', () => {
+ expect(buildCanonicalBody(42)).toBe('42');
+ });
+
+ it('should stringify true', () => {
+ expect(buildCanonicalBody(true)).toBe('true');
+ });
+
+ it('should return empty string for false (falsy)', () => {
+ // false is falsy, so it returns '' (same as null/undefined)
+ expect(buildCanonicalBody(false)).toBe('');
+ });
+
+ it('should handle complex nested structure with top-level sorting only', () => {
+ const body = {
+ walletId: 'wallet-123',
+ amount: { currency: 'CHF', value: '1000' },
+ name: 'Test Payment',
+ };
+ // Top-level keys sorted: amount, name, walletId
+ expect(buildCanonicalBody(body)).toBe(
+ '{"amount":{"currency":"CHF","value":"1000"},"name":"Test Payment","walletId":"wallet-123"}',
+ );
+ });
+ });
+
+ // --- Tests for createCanonicalString --- //
+
+ describe('createCanonicalString', () => {
+ it('should create canonical string for GET request without body', () => {
+ const result = createCanonicalString('GET', '/v1/auth/context');
+ expect(result).toBe('GET/v1/auth/context');
+ });
+
+ it('should create canonical string for GET request with query params', () => {
+ const result = createCanonicalString('GET', '/v1/wallets?pageNumber=1&pageSize=10');
+ expect(result).toBe('GET/v1/wallets?pageNumber=1&pageSize=10');
+ });
+
+ it('should uppercase the method', () => {
+ const result = createCanonicalString('get', '/v1/auth/context');
+ expect(result).toBe('GET/v1/auth/context');
+ });
+
+ it('should create canonical string for POST request with body', () => {
+ const body = { targetWalletId: 'target', sourceWalletId: 'source' };
+ const result = createCanonicalString('POST', '/v1/quotes-fx', body);
+ // Body keys sorted: sourceWalletId, targetWalletId
+ expect(result).toBe('POST/v1/quotes-fx{"sourceWalletId":"source","targetWalletId":"target"}');
+ });
+
+ it('should handle DELETE request', () => {
+ const result = createCanonicalString('DELETE', '/v1/accounts/123/payments/456');
+ expect(result).toBe('DELETE/v1/accounts/123/payments/456');
+ });
+ });
+
+ // --- Tests for Amount Conversion --- //
+
+ describe('convertRelioAmount', () => {
+ it('should convert minor units to major units', () => {
+ expect(convertRelioAmount('98434500')).toBe(984345);
+ });
+
+ it('should handle cents correctly', () => {
+ expect(convertRelioAmount('10050')).toBe(100.5);
+ });
+
+ it('should handle zero', () => {
+ expect(convertRelioAmount('0')).toBe(0);
+ });
+
+ it('should return 0 for invalid input', () => {
+ expect(convertRelioAmount('invalid')).toBe(0);
+ });
+
+ it('should return 0 for empty string', () => {
+ expect(convertRelioAmount('')).toBe(0);
+ });
+ });
+
+ describe('toRelioAmount', () => {
+ it('should convert major units to minor units', () => {
+ expect(toRelioAmount(984345)).toBe('98434500');
+ });
+
+ it('should handle decimals correctly', () => {
+ expect(toRelioAmount(100.5)).toBe('10050');
+ });
+
+ it('should round to nearest cent', () => {
+ expect(toRelioAmount(100.555)).toBe('10056');
+ expect(toRelioAmount(100.554)).toBe('10055');
+ });
+
+ it('should handle zero', () => {
+ expect(toRelioAmount(0)).toBe('0');
+ });
+
+ it('should handle small amounts', () => {
+ expect(toRelioAmount(0.01)).toBe('1');
+ });
+ });
+});
From fdaa0e9fb487e709061db3c3442d71376b9a50bf Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 20:50:22 +0100
Subject: [PATCH 09/10] Add automatic .env file loading to relio test script
The script now reads configuration from .env automatically,
eliminating the need to set environment variables manually.
---
scripts/test-relio.ts | 27 ++++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/scripts/test-relio.ts b/scripts/test-relio.ts
index a351669332..8685358f5a 100644
--- a/scripts/test-relio.ts
+++ b/scripts/test-relio.ts
@@ -3,16 +3,33 @@
*
* Run with: npx ts-node scripts/test-relio.ts
*
- * Required environment variables:
- * RELIO_BASE_URL=https://api.develio.ch/v1
- * RELIO_API_KEY=
- * RELIO_PRIVATE_KEY=
- * RELIO_ORGANIZATION_ID=
+ * Reads configuration from .env file automatically.
*/
import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as path from 'path';
import axios from 'axios';
+// Load .env file manually (to avoid dotenv dependency issues)
+const envPath = path.join(process.cwd(), '.env');
+if (fs.existsSync(envPath)) {
+ const envContent = fs.readFileSync(envPath, 'utf8');
+ for (const line of envContent.split('\n')) {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith('#')) {
+ const eqIndex = trimmed.indexOf('=');
+ if (eqIndex > 0) {
+ const key = trimmed.substring(0, eqIndex);
+ const value = trimmed.substring(eqIndex + 1);
+ if (!process.env[key]) {
+ process.env[key] = value;
+ }
+ }
+ }
+ }
+}
+
// Configuration from environment
const config = {
baseUrl: process.env.RELIO_BASE_URL,
From 23c85e46c238f57df6f5355f3666ac55f515f4ef Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 15 Jan 2026 20:52:45 +0100
Subject: [PATCH 10/10] Fix CodeQL security finding: remove API key logging
Replace partial API key logging with simple "Loaded" indicator
to address clear-text logging of sensitive information warning.
---
scripts/test-relio.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/test-relio.ts b/scripts/test-relio.ts
index 8685358f5a..10cc18201d 100644
--- a/scripts/test-relio.ts
+++ b/scripts/test-relio.ts
@@ -171,7 +171,7 @@ async function main() {
// Check configuration
console.log('\nConfiguration:');
console.log(' Base URL:', config.baseUrl || 'MISSING');
- console.log(' API Key:', config.apiKey ? config.apiKey.substring(0, 20) + '...' : 'MISSING');
+ console.log(' API Key:', config.apiKey ? 'Loaded' : 'MISSING');
console.log(' Private Key:', privateKey ? 'Loaded' : 'MISSING or INVALID');
console.log(' Organization ID:', config.organizationId || 'MISSING');