diff --git a/features/payment-qr/__tests__/paymentUri.test.ts b/features/payment-qr/__tests__/paymentUri.test.ts
index 7d8802c9..5a125162 100644
--- a/features/payment-qr/__tests__/paymentUri.test.ts
+++ b/features/payment-qr/__tests__/paymentUri.test.ts
@@ -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,
diff --git a/features/payment-qr/components/PaymentQrForm.tsx b/features/payment-qr/components/PaymentQrForm.tsx
index 9a2ae624..3b027223 100644
--- a/features/payment-qr/components/PaymentQrForm.tsx
+++ b/features/payment-qr/components/PaymentQrForm.tsx
@@ -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";
@@ -25,6 +31,7 @@ export function PaymentQrForm({
assetKind: "native",
assetCode: "",
assetIssuer: "",
+ memoType: "text",
memo: "",
msg: ""
});
@@ -134,6 +141,28 @@ export function PaymentQrForm({
) : null}
+
+ {({ inputId }) => (
+
+ )}
+
+
{({ inputId, describedBy, invalid }) => (
set("memo", event.target.value)}
- placeholder="Invoice 1001"
+ placeholder={MEMO_PLACEHOLDERS[form.memoType]}
+ inputMode={form.memoType === "id" ? "numeric" : "text"}
autoComplete="off"
/>
)}
diff --git a/features/payment-qr/copy.ts b/features/payment-qr/copy.ts
index fc7d6e51..c7e5385d 100644
--- a/features/payment-qr/copy.ts
+++ b/features/payment-qr/copy.ts
@@ -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",
@@ -54,6 +55,10 @@ export const errorCopy: Record = {
+ text: "MEMO_TEXT",
+ id: "MEMO_ID",
+ hash: "MEMO_HASH",
+ return: "MEMO_RETURN"
+};
+
+export const PAYMENT_MEMO_TYPE_GUIDANCE: Record = {
+ 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 = {
+ 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.");
+ }
+}
diff --git a/features/payment-qr/lib/paymentUri.ts b/features/payment-qr/lib/paymentUri.ts
index 766ec55d..5110afbf 100644
--- a/features/payment-qr/lib/paymentUri.ts
+++ b/features/payment-qr/lib/paymentUri.ts
@@ -1,3 +1,4 @@
+import { PAYMENT_MEMO_TYPE_URI_VALUES } from "@/features/payment-qr/lib/memoTypes";
import type { PaymentRequest } from "@/features/payment-qr/types";
/**
@@ -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);
diff --git a/features/payment-qr/schema.ts b/features/payment-qr/schema.ts
index e35613c1..89ba2c56 100644
--- a/features/payment-qr/schema.ts
+++ b/features/payment-qr/schema.ts
@@ -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,
@@ -25,6 +29,7 @@ export const FIELD_OF_CODE: Record =
invalid_asset_code: "assetCode",
invalid_asset_issuer: "assetIssuer",
memo_too_long: "memo",
+ invalid_memo: "memo",
message_too_long: "msg",
qr_generation_failed: null
};
@@ -35,6 +40,7 @@ export interface RawPaymentForm {
assetKind: "native" | "issued";
assetCode: string;
assetIssuer: string;
+ memoType: PaymentMemoType;
memo: string;
msg: string;
}
@@ -48,7 +54,8 @@ export function parsePaymentRequest(
): Result {
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");
@@ -58,9 +65,24 @@ 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") {
@@ -68,7 +90,8 @@ export function parsePaymentRequest(
destination,
amount,
asset: { kind: "native" },
- memo: memo || undefined,
+ memo,
+ memoType: resolvedMemoType,
msg: msg || undefined
});
}
@@ -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
});
}
diff --git a/features/payment-qr/types.ts b/features/payment-qr/types.ts
index 3787c1c7..7fd0bfff 100644
--- a/features/payment-qr/types.ts
+++ b/features/payment-qr/types.ts
@@ -1,3 +1,5 @@
+import type { PaymentMemoType } from "@/features/payment-qr/lib/memoTypes";
+
export type PaymentAsset =
| { kind: "native" }
| { kind: "issued"; code: string; issuer: string };
@@ -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;
}
@@ -27,6 +30,7 @@ export type PaymentQrErrorCode =
| "invalid_asset_code"
| "invalid_asset_issuer"
| "memo_too_long"
+ | "invalid_memo"
| "message_too_long"
| "qr_generation_failed";
@@ -35,5 +39,6 @@ export type PaymentQrField =
| "amount"
| "assetCode"
| "assetIssuer"
+ | "memoType"
| "memo"
| "msg";