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
40 changes: 40 additions & 0 deletions features/payment-qr/__tests__/paymentUri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ describe("buildPaymentUri", () => {
expect(buildPaymentUri(request)).toBe(buildPaymentUri(request));
});

it("includes memo_type for ID, hash, and return memos", () => {
const validHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

expect(
parsePaymentUri(
buildPaymentUri({
destination,
amount: "10",
asset: { kind: "native" },
memo: "9223372036854775807",
memoType: "id"
})
)
).toMatchObject({ memo: "9223372036854775807", memo_type: "MEMO_ID" });

expect(
parsePaymentUri(
buildPaymentUri({
destination,
amount: "10",
asset: { kind: "native" },
memo: validHash,
memoType: "hash"
})
)
).toMatchObject({ memo: validHash, memo_type: "MEMO_HASH" });

expect(
parsePaymentUri(
buildPaymentUri({
destination,
amount: "10",
asset: { kind: "native" },
memo: validHash,
memoType: "return"
})
)
).toMatchObject({ memo: validHash, memo_type: "MEMO_RETURN" });
});

it("round-trips through the parser", () => {
const uri = buildPaymentUri({
destination,
Expand Down
32 changes: 31 additions & 1 deletion features/payment-qr/components/PaymentQrForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { Button } from "@/core/ui/Button";
import { Field } from "@/core/ui/Field";
import { Input, Select } from "@/core/ui/Input";
import { copy } from "@/features/payment-qr/copy";
import {
MEMO_PLACEHOLDERS,
PAYMENT_MEMO_TYPE_GUIDANCE,
PAYMENT_MEMO_TYPES,
type PaymentMemoType
} from "@/features/payment-qr/lib/memoTypes";
import type { RawPaymentForm } from "@/features/payment-qr/schema";
import type { PaymentQrField } from "@/features/payment-qr/types";

Expand All @@ -25,6 +31,7 @@ export function PaymentQrForm({
assetKind: "native",
assetCode: "",
assetIssuer: "",
memoType: "text",
memo: "",
msg: ""
});
Expand Down Expand Up @@ -134,6 +141,28 @@ export function PaymentQrForm({
) : null}

<div className="grid gap-4 sm:grid-cols-2">
<Field label={copy.memoTypeLabel} hint={PAYMENT_MEMO_TYPE_GUIDANCE[form.memoType]}>
{({ inputId }) => (
<Select
id={inputId}
value={form.memoType}
onChange={(event) => set("memoType", event.target.value as PaymentMemoType)}
>
{PAYMENT_MEMO_TYPES.map((type) => (
<option key={type} value={type}>
{type === "text"
? "Text memo"
: type === "id"
? "ID memo"
: type === "hash"
? "Hash memo"
: "Return memo"}
</option>
))}
</Select>
)}
</Field>

<Field label={copy.memoLabel} hint={copy.memoHint} error={errorFor("memo")}>
{({ inputId, describedBy, invalid }) => (
<Input
Expand All @@ -142,7 +171,8 @@ export function PaymentQrForm({
aria-invalid={invalid}
value={form.memo}
onChange={(event) => set("memo", event.target.value)}
placeholder="Invoice 1001"
placeholder={MEMO_PLACEHOLDERS[form.memoType]}
inputMode={form.memoType === "id" ? "numeric" : "text"}
autoComplete="off"
/>
)}
Expand Down
7 changes: 6 additions & 1 deletion features/payment-qr/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export const copy = {
assetIssued: "Issued asset",
assetCodeLabel: "Asset code",
assetIssuerLabel: "Asset issuer",
memoTypeLabel: "Memo type (optional)",
memoLabel: "Memo (optional)",
memoHint: "A text memo, up to 28 bytes. Emoji and accented letters cost more than one byte.",
memoHint: "Choose a memo type, then enter a value that matches Stellar limits for that type.",
msgLabel: "Message to the payer (optional)",
msgHint: "Shown by the wallet before the payer confirms. Up to 300 characters.",
submit: "Generate QR code",
Expand Down Expand Up @@ -54,6 +55,10 @@ export const errorCopy: Record<PaymentQrErrorCode, { title: string; description:
title: "The memo is too long",
description: "A Stellar text memo holds 28 bytes. Non-ASCII characters use more than one byte each."
},
invalid_memo: {
title: "That memo value is not valid",
description: "Check the memo type guidance and enter a value that matches Stellar limits."
},
message_too_long: {
title: "The message is too long",
description: "SEP-0007 limits the payer message to 300 characters."
Expand Down
1 change: 1 addition & 0 deletions features/payment-qr/fixtures/paymentQr.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const nativeForm: RawPaymentForm = {
assetKind: "native",
assetCode: "",
assetIssuer: "",
memoType: "text",
memo: "",
msg: ""
};
Expand Down
66 changes: 66 additions & 0 deletions features/payment-qr/lib/memoTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export type PaymentMemoType = "text" | "id" | "hash" | "return";

export const PAYMENT_MEMO_TYPES: readonly PaymentMemoType[] = ["text", "id", "hash", "return"];

export const PAYMENT_MEMO_TYPE_URI_VALUES: Record<PaymentMemoType, string> = {
text: "MEMO_TEXT",
id: "MEMO_ID",
hash: "MEMO_HASH",
return: "MEMO_RETURN"
};

export const PAYMENT_MEMO_TYPE_GUIDANCE: Record<PaymentMemoType, string> = {
text: "Use up to 28 bytes for invoices, order IDs, or short notes.",
id: "Use a whole number from 0 to 18446744073709551615.",
hash: "Use exactly 64 hexadecimal characters for a 32-byte hash.",
return: "Use exactly 64 hexadecimal characters for a 32-byte return hash."
};

export const MEMO_PLACEHOLDERS: Record<PaymentMemoType, string> = {
text: "Invoice 1001",
id: "9223372036854775807",
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
return: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
};

const MAX_UINT64 = BigInt("18446744073709551615");

export function normalizePaymentMemo(memoType: PaymentMemoType, memo: string): string {
const value = memo.trim();

if (!value) {
throw new Error("Enter a memo value for the selected memo type.");
}

switch (memoType) {
case "text":
return value;
case "id": {
if (!/^\d+$/.test(value)) {
throw new Error("Memo ID must be a whole number from 0 to 18446744073709551615.");
}

const memoId = BigInt(value);

if (memoId < 0n || memoId > MAX_UINT64) {
throw new Error("Memo ID must be a whole number from 0 to 18446744073709551615.");
}

return value;
}
case "hash":
case "return": {
if (!/^[0-9a-fA-F]{64}$/.test(value)) {
throw new Error(
memoType === "hash"
? "Memo hash must be exactly 64 hexadecimal characters."
: "Memo return value must be exactly 64 hexadecimal characters."
);
}

return value.toLowerCase();
}
default:
throw new Error("Choose a supported memo type.");
}
}
4 changes: 3 additions & 1 deletion features/payment-qr/lib/paymentUri.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PAYMENT_MEMO_TYPE_URI_VALUES } from "@/features/payment-qr/lib/memoTypes";
import type { PaymentRequest } from "@/features/payment-qr/types";

/**
Expand All @@ -17,8 +18,9 @@ export function buildPaymentUri(request: PaymentRequest): string {
}

if (request.memo) {
const memoType = request.memoType ?? "text";
params.set("memo", request.memo);
params.set("memo_type", "MEMO_TEXT");
params.set("memo_type", PAYMENT_MEMO_TYPE_URI_VALUES[memoType]);
}

if (request.msg) params.set("msg", request.msg);
Expand Down
36 changes: 30 additions & 6 deletions features/payment-qr/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { StrKey } from "@stellar/stellar-sdk";
import { err, ok, type Result } from "@/core/result/result";
import {
normalizePaymentMemo,
type PaymentMemoType
} from "@/features/payment-qr/lib/memoTypes";
import type {
PaymentQrErrorCode,
PaymentQrField,
Expand All @@ -25,6 +29,7 @@ export const FIELD_OF_CODE: Record<PaymentQrErrorCode, PaymentQrField | null> =
invalid_asset_code: "assetCode",
invalid_asset_issuer: "assetIssuer",
memo_too_long: "memo",
invalid_memo: "memo",
message_too_long: "msg",
qr_generation_failed: null
};
Expand All @@ -35,6 +40,7 @@ export interface RawPaymentForm {
assetKind: "native" | "issued";
assetCode: string;
assetIssuer: string;
memoType: PaymentMemoType;
memo: string;
msg: string;
}
Expand All @@ -48,7 +54,8 @@ export function parsePaymentRequest(
): Result<PaymentRequest, PaymentQrErrorCode> {
const destination = raw.destination.replace(/\s+/g, "");
const amount = raw.amount.trim();
const memo = raw.memo.trim();
const memoType = raw.memoType ?? "text";
const memoInput = raw.memo.trim();
const msg = raw.msg.trim();

if (!destination) return err("empty_destination");
Expand All @@ -58,17 +65,33 @@ export function parsePaymentRequest(
if (!AMOUNT.test(amount) || Number(amount) <= 0) return err("invalid_amount");
if ((amount.split(".")[1] ?? "").length > MAX_DECIMALS) return err("amount_too_precise");

// A text memo is limited by bytes, not characters: emoji and accented
// letters cost more than one byte each.
if (memo && byteLength(memo) > MEMO_MAX_BYTES) return err("memo_too_long");
let memo: string | undefined;
let resolvedMemoType: PaymentMemoType | undefined;

if (memoInput) {
if (memoType === "text") {
if (byteLength(memoInput) > MEMO_MAX_BYTES) return err("memo_too_long");
memo = memoInput;
resolvedMemoType = "text";
} else {
try {
memo = normalizePaymentMemo(memoType, memoInput);
resolvedMemoType = memoType;
} catch {
return err("invalid_memo");
}
}
}

if (msg.length > MSG_MAX_LENGTH) return err("message_too_long");

if (raw.assetKind === "native") {
return ok({
destination,
amount,
asset: { kind: "native" },
memo: memo || undefined,
memo,
memoType: resolvedMemoType,
msg: msg || undefined
});
}
Expand All @@ -84,7 +107,8 @@ export function parsePaymentRequest(
destination,
amount,
asset: { kind: "issued", code: code.toUpperCase(), issuer },
memo: memo || undefined,
memo,
memoType: resolvedMemoType,
msg: msg || undefined
});
}
5 changes: 5 additions & 0 deletions features/payment-qr/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PaymentMemoType } from "@/features/payment-qr/lib/memoTypes";

export type PaymentAsset =
| { kind: "native" }
| { kind: "issued"; code: string; issuer: string };
Expand All @@ -7,6 +9,7 @@ export interface PaymentRequest {
amount: string;
asset: PaymentAsset;
memo?: string;
memoType?: PaymentMemoType;
/** Free-text label shown by the wallet, from SEP-0007. */
msg?: string;
}
Expand All @@ -27,6 +30,7 @@ export type PaymentQrErrorCode =
| "invalid_asset_code"
| "invalid_asset_issuer"
| "memo_too_long"
| "invalid_memo"
| "message_too_long"
| "qr_generation_failed";

Expand All @@ -35,5 +39,6 @@ export type PaymentQrField =
| "amount"
| "assetCode"
| "assetIssuer"
| "memoType"
| "memo"
| "msg";
Loading