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
31 changes: 30 additions & 1 deletion apps/wallet/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useLocales } from "expo-localization";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";

import { type PoiProfile } from "./src/epc/poi";
import { type Payee } from "./src/epc/request";
import { readOpenedLink, type OpenedRequest } from "./src/epc/scan";
import { resolveLocale, type Language } from "./src/i18n";
Expand Down Expand Up @@ -42,6 +43,7 @@ import { HistoryScreen } from "./src/ui/HistoryScreen";
import { type Tab, TabBar } from "./src/ui/kit";
import { PayeeScreen } from "./src/ui/PayeeScreen";
import { PayeesScreen } from "./src/ui/PayeesScreen";
import { PoiScreen } from "./src/ui/PoiScreen";
import { RequestScreen } from "./src/ui/RequestScreen";
import { ScanScreen } from "./src/ui/ScanScreen";
import { SettingsScreen } from "./src/ui/SettingsScreen";
Expand All @@ -62,6 +64,7 @@ type Origin = "tab" | "settings";
type Screen =
| { name: "tab" }
| { name: "settings" }
| { name: "poi" }
| { name: "payees"; origin: Origin }
| { name: "payee"; editing: number | null; origin: Origin; fromList: boolean };

Expand Down Expand Up @@ -152,6 +155,10 @@ export default function App() {
(chosen: Language | null) => commit((current) => ({ ...current, language: chosen })),
[commit],
);
const onPoiProfile = useCallback(
(profile: PoiProfile | null) => commit((current) => ({ ...current, poi: profile })),
[commit],
);

return (
<AppearanceContext.Provider value={appearance}>
Expand All @@ -162,6 +169,7 @@ export default function App() {
deviceLanguage={deviceLanguage}
onAppearance={onAppearance}
onLanguage={onLanguage}
onPoiProfile={onPoiProfile}
/>
</LocaleProvider>
</AppearanceContext.Provider>
Expand All @@ -174,6 +182,7 @@ interface WalletProps {
deviceLanguage: Language;
onAppearance: (appearance: Preferences["appearance"]) => Promise<void>;
onLanguage: (language: Language | null) => Promise<void>;
onPoiProfile: (profile: PoiProfile | null) => Promise<void>;
}

function Wallet({
Expand All @@ -182,6 +191,7 @@ function Wallet({
deviceLanguage,
onAppearance,
onLanguage,
onPoiProfile,
}: WalletProps) {
const strings = useStrings();
const scheme = useScheme();
Expand Down Expand Up @@ -283,7 +293,8 @@ function Wallet({

// The system back gesture or button on Android leaves a screen opened over
// the tabs the way its own back link does. On the tabs it keeps its meaning,
// and the payee form handles it itself, since Cancel waits while it writes.
// and the payee and EN 18184 forms handle it themselves, since Cancel waits
// while they write.
useEffect(() => {
if (screen.name !== "settings" && screen.name !== "payees") return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
Expand Down Expand Up @@ -324,6 +335,14 @@ function Wallet({
},
[commitBook],
);
// Saved or turned off, the form returns to the settings it was opened from.
const onSavePoi = useCallback(
async (profile: PoiProfile | null) => {
await onPoiProfile(profile);
setScreen((current) => (current.name === "poi" ? { name: "settings" } : current));
},
[onPoiProfile],
);
const onUsePayee = useCallback(
(index: number) => commitBook((current) => setActivePayee(current, index)),
[commitBook],
Expand Down Expand Up @@ -453,6 +472,14 @@ function Wallet({
notice={book.loadFailed ? strings.payee.readFailed : null}
/>
</SafeAreaView>
) : screen.name === "poi" ? (
<SafeAreaView style={styles.root} edges={["bottom"]}>
<PoiScreen
profile={preferences.poi}
onSave={onSavePoi}
onCancel={() => setScreen({ name: "settings" })}
/>
</SafeAreaView>
) : screen.name === "payees" ? (
<SafeAreaView style={styles.root} edges={["bottom"]}>
<PayeesScreen
Expand All @@ -474,6 +501,7 @@ function Wallet({
payeeCount={payees.length}
activeName={activeName}
onPayees={() => choosePayee("settings")}
onPoi={() => setScreen({ name: "poi" })}
onExport={onExport}
exportBlocked={exportBlocked}
preferences={preferences}
Expand Down Expand Up @@ -504,6 +532,7 @@ function Wallet({
}
onSettings={() => setScreen({ name: "settings" })}
onKeep={onKeep}
poiProfile={preferences.poi}
/>
</View>
{tab === "pay" ? (
Expand Down
71 changes: 67 additions & 4 deletions apps/wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ the payees, the appearance, the language and the file that moves the data to ano
wallet speaks English, German, French, Spanish, Italian, Dutch and Polish, and follows the device
language until one is chosen by hand. The screens follow the system appearance, light or dark, or
the one chosen in the settings, and a native build shows the logo on a splash screen while the
settings are read. EN 18184 codes are not supported yet; see the checklist on the tracking issue.
settings are read. EN 18184 codes are read, and built once the settings hold the framework
details they need; see [EN 18184 codes](#en-18184-codes).

## Languages and appearance

Expand Down Expand Up @@ -58,7 +59,8 @@ restarts, so a failed read never turns into a lost list.

Settings offers an export and an import. The export writes the payees and the kept requests to
one JSON file and hands it to the share sheet of the operating system, so where it goes is the
user's choice; the wallet sends nothing itself. The file names its format and a version number,
user's choice; the wallet sends nothing itself. The appearance, the language and the EN 18184
details are settings of the device and stay out of the file. The file names its format and a version number,
so a later shape of the file can still read an earlier export, and an export written by a newer
wallet is refused with a message rather than misread.

Expand Down Expand Up @@ -207,6 +209,67 @@ The app does not register `payto` with the operating system, so a tapped payto l
open it. The handoff itself opens a payto URI, so a wallet registered for the scheme would be
offered its own handoff.

## EN 18184 codes

EN 18184:2025, published from EPC024-22, is the point-of-interaction format: a code is an https
URL rather than the line-based EPC069-12 payload.

```
https://<framework domain>/1/<payment context>/<provider ID>/?<payload>
```

The domain belongs to an MSCT interoperability framework, the provider ID routes the payment to
the payee's MSCT service provider and the payload names its issuer by another ID. The framework
issues all three, and nothing in the code can be looked up to find them, so the wallet builds no
EN 18184 code until they are entered under Settings, EN 18184 codes. The form checks them with the
encoder: a bare domain name, not an IP address in any spelling and not an internationalised
(punycode) name, which URL parsers read differently, and two IDs of 3 letters or digits. From
then on the request screen offers a choice of format, EPC QR or EN 18184, and for EN 18184 a
choice of an instant or a standard transfer. The code is built with the payload profile that carries all data in clear, in
the person-to-person context `p`, and uses the parameter names of the Euvena profile v1 that
`@euvena/qr` documents.

What changes for the payee against an EPC069-12 code:

| | EPC069-12 | EN 18184 |
| --- | --- | --- |
| Read by | Most European banking apps | Apps in an EN 18184 framework |
| Amount | Optional; the payer can decide | Required |
| Remittance | Text up to 140 characters, or a creditor reference | Text or a structured reference, up to 35 characters |
| BIC | Carried when the payee has one | Not carried; the format has no element for it |
| Transfer | Not stated | Instant or standard, as chosen |
| Symbol | Level M, version 13 at most, as EPC069-12 requires | Level M; EPC024-22 names ISO/IEC 18004 and fixes no version, so up to 40 |

A kept EN 18184 request goes into the history like any other and a shared one travels as its own
URL, since that is already the request. Turning the codes off in the settings brings the request
screen back to EPC069-12 only; the draft keeps what was picked for when they are set up again.

EPC024-22 leaves the query parameter names to the payload issuer, so the wallet reads the names
of the Euvena profile v1 only. A conformant code from an issuer that chose other names is refused
as an address the wallet cannot read as an EN 18184 code.

On the paying side a scanned or pasted `https://` address goes to the EN 18184 decoder, with the
scheme and host compared without case, since EPC024-22 writes its examples in capitals. A code with
all data in clear gets the same review as an EPC069-12 code, plus the transfer the payee asks for,
any trade name, reference party and merchant category, the payment context, and the framework,
provider and issuer the address names. A phone camera would have opened that address; the wallet
reads it on the device and opens nothing, and the review says so. When the payee asks for an
instant transfer, the handoff says so too, because neither the payto link nor a transfer form field
carries it.

Refused, as a whole:

| Code | Why |
| --- | --- |
| Token or proxy profile | The token or proxy stands for payee data that only the payee's provider can resolve, over its own network |
| A currency other than euro, compared without case | SEPA transfers are made in euro, and the handoff states a euro amount |
| Any field that fails a check, or any parameter given twice | Named by element, never quoted, as for EPC069-12 |
| An https address without the EN 18184 structure or the Euvena profile v1 names, served from an IP address or a punycode host, or with credentials, a port or a fragment | The wallet cannot read it as an EN 18184 code, and opens nothing |

The codec parses the URL with the global `URL`. React Native's own `URL` is not the WHATWG one and
cuts a value at a second `=`, but Expo's runtime replaces it with a WHATWG implementation, against
which the codec's EN 18184 tests pass unchanged.

## Running it

From the repository root:
Expand Down Expand Up @@ -238,10 +301,10 @@ pnpm --filter @euvena/wallet build # bundles the JS, no native toolchain requi
| --- | --- |
| `App.tsx` | Root component, loads the payee settings, opens incoming links, holds the tab bar and switches between the screens |
| `plugins/` | Config plugin that keeps a restored Android activity from reopening its launch link |
| `src/epc/` | Form state to EPC069-12 payload, the link form of a request, plus the display formatting |
| `src/epc/` | Form state to an EPC069-12 payload or an EN 18184 URL (`poi.ts`), the link form of a request, plus the display formatting |
| `src/i18n/` | The typed dictionary shape, one file per language, language resolution and number and date formatting |
| `src/qr/` | QR symbol construction and its SVG path |
| `src/settings/` | Payee settings, the appearance and language preferences, the request history and the data file that moves them, on-device only |
| `src/settings/` | Payee settings, the appearance, language and EN 18184 preferences, the request history and the data file that moves them, on-device only |
| `src/ui/` | Screens, the building blocks they share (`kit.tsx`), the palette for both appearances (`theme.ts`), the QR view and the file share and pick (`dataFile.ts`) |
| `metro.config.js` | Workspace-aware resolver so `packages/*` resolve and hot-reload |
| `test/` | Plain-TypeScript tests; the React Native surface is covered by typecheck and lint |
Expand Down
21 changes: 9 additions & 12 deletions apps/wallet/src/epc/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import { EpcQrError, decodeEpcQr, type EpcQrData } from "@euvena/qr";

import { type Dictionary, type Rejection } from "../i18n";
import { summarizeRequest } from "./request";
import { summarizeRequest, type PaymentCode } from "./request";

/** URI scheme of a shared request. */
export const REQUEST_LINK_SCHEME = "euvena";
Expand All @@ -34,7 +34,7 @@ export const REQUEST_LINK_ACTION = "request";
export const REQUEST_LINK_PARAM = "epc";

export type ParsedRequestLink =
| { ok: true; payload: string; data: EpcQrData }
| { ok: true; format: "epc069"; payload: string; data: EpcQrData }
| { ok: false; reason: Rejection };

const NOT_A_REQUEST_LINK: Rejection = { code: "linkNot" };
Expand Down Expand Up @@ -113,7 +113,7 @@ export function parseRequestLink(link: string): ParsedRequestLink {
}

try {
return { ok: true, payload, data: decodeEpcQr(payload).data };
return { ok: true, format: "epc069", payload, data: decodeEpcQr(payload).data };
} catch (error) {
if (error instanceof EpcQrError) {
// The codec's message can repeat the input it rejected, and a link's
Expand Down Expand Up @@ -149,14 +149,11 @@ function findParameter(query: string, name: string): string | undefined {
*
* The summary is for the person reading the message and the link is what
* carries the request; both are built from the decoded payload, so neither can
* drift from the code.
* drift from the code. An EN 18184 code is a URL already, so it travels as
* itself.
*/
export function buildShareMessage(
data: EpcQrData,
payload: string,
strings: Dictionary,
tag: string,
): string {
const lines = summarizeRequest(data, strings, tag).map((row) => `${row.label}: ${row.value}`);
return `${lines.join("\n")}\n\n${buildRequestLink(payload)}`;
export function buildShareMessage(code: PaymentCode, strings: Dictionary, tag: string): string {
const lines = summarizeRequest(code, strings, tag).map((row) => `${row.label}: ${row.value}`);
const link = code.format === "epc069" ? buildRequestLink(code.payload) : code.payload;
return `${lines.join("\n")}\n\n${link}`;
}
19 changes: 16 additions & 3 deletions apps/wallet/src/epc/payto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* code likewise has no generic option and stays visible in the review only.
*/

import { isValidAmountString, type EncodeEpcQrOptions, type EpcQrData } from "@euvena/qr";
import { isValidAmountString, type EncodeEpcQrOptions } from "@euvena/qr";

import { type Rejection } from "../i18n";

Expand All @@ -33,7 +33,20 @@ export const PAYTO_SCHEME = "payto";
/** The only payment target type the wallet reads: a SEPA account by IBAN. */
const IBAN_TARGET = "iban";

export function buildPaytoUri(data: EpcQrData): string {
/**
* What a handoff carries, which a code of either format can say. The amount
* is in euro: both readers refuse any other currency.
*/
export interface TransferDetails {
name: string;
iban: string;
bic?: string | undefined;
amount?: string | undefined;
reference?: string | undefined;
text?: string | undefined;
}

export function buildPaytoUri(data: TransferDetails): string {
const path =
data.bic === undefined
? encodeURIComponent(data.iban)
Expand Down Expand Up @@ -72,7 +85,7 @@ export interface HandoffField {
* without display grouping and the amount as the codec's canonical string,
* because bank forms are filled field by field and reject decoration.
*/
export function handoffFields(data: EpcQrData): HandoffField[] {
export function handoffFields(data: TransferDetails): HandoffField[] {
const fields: HandoffField[] = [
{ key: "payee", value: data.name },
{ key: "iban", value: data.iban },
Expand Down
Loading
Loading