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";