diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index 9b4bc8d6..de848174 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -233,7 +233,34 @@ RoutingResult extractRoutingSync(RoutingInput input) { /// /// Currently delegates to [extractRoutingSync]; when async capabilities /// are added this function will perform the additional checks. -Future extractRouting(RoutingInput input) async { - return extractRoutingSync(input); -} +typedef MemoRequirementFetcher = Future Function(String baseAccount); + +/// Performs routing extraction and optionally checks a destination's SEP-0029 +/// memo requirement. Fetch failures fail open to preserve parser behavior. +Future extractRouting( + RoutingInput input, { + MemoRequirementFetcher? fetchMemoRequirement, +}) async { + final result = extractRoutingSync(input); + if (fetchMemoRequirement == null || + result.destinationBaseAccount == null || + result.id != null || + result.destinationError != null) { + return result; + } + try { + if (await fetchMemoRequirement(result.destinationBaseAccount!)) { + return RoutingResult( + source: result.source, + id: result.id, + destinationBaseAccount: result.destinationBaseAccount, + destinationError: result.destinationError, + warnings: [...result.warnings, RoutingWarning.missingRequiredMemo], + ); + } + } catch (_) { + // Network/configuration failures must not change the synchronous result. + } + return result; +} diff --git a/packages/core-dart/lib/src/routing/routing_result.dart b/packages/core-dart/lib/src/routing/routing_result.dart index 56060c98..0b90cc2f 100644 --- a/packages/core-dart/lib/src/routing/routing_result.dart +++ b/packages/core-dart/lib/src/routing/routing_result.dart @@ -61,6 +61,13 @@ class RoutingWarning { message: 'Contract source detected. Routing state cleared.', ); + /// Emitted when SEP-0029 requires a memo but no routing ID was supplied. + static const missingRequiredMemo = RoutingWarning( + code: 'MISSING_REQUIRED_MEMO', + severity: 'error', + message: 'Destination account requires a memo, but no routing ID was provided.', + ); + @override String toString() => '[$severity] $code: $message'; diff --git a/packages/core-go/address/warnings.go b/packages/core-go/address/warnings.go index aacb2fda..63be33f1 100644 --- a/packages/core-go/address/warnings.go +++ b/packages/core-go/address/warnings.go @@ -18,6 +18,7 @@ const ( WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" WarnUnsupportedMemoType WarningCode = "UNSUPPORTED_MEMO_TYPE" WarnInvalidDestination WarningCode = "INVALID_DESTINATION" + WarnMissingRequiredMemo WarningCode = "MISSING_REQUIRED_MEMO" ) type Warning struct { diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index f49bcaa4..5d333262 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -202,6 +202,30 @@ func ExtractRouting(input RoutingInput) RoutingResult { } } +// MemoRequirementFetcher retrieves whether a destination account requires a +// routing memo. Implementations can use Horizon, an indexer, or a cached source. +type MemoRequirementFetcher func(baseAccount string) (bool, error) + +// ExtractRoutingWithMemoRequirement performs normal routing extraction and +// optionally adds the SEP-0029 error when a classic destination requires a +// memo but no routing ID was supplied. Fetch failures fail open so callers +// retain the result of the synchronous parser. +func ExtractRoutingWithMemoRequirement(input RoutingInput, fetch MemoRequirementFetcher) RoutingResult { + result := ExtractRouting(input) + if fetch == nil || result.DestinationBaseAccount == "" || result.RoutingID != nil || result.DestinationError != nil { + return result + } + required, err := fetch(result.DestinationBaseAccount) + if err == nil && required { + result.Warnings = append(result.Warnings, address.Warning{ + Code: address.WarnMissingRequiredMemo, + Severity: "error", + Message: "Destination account requires a memo, but no routing ID was provided.", + }) + } + return result +} + func stringValue(s string) string { return s -} \ No newline at end of file +} diff --git a/packages/core-ts/src/address/types.ts b/packages/core-ts/src/address/types.ts index d74fc2a5..5c2c1ff1 100644 --- a/packages/core-ts/src/address/types.ts +++ b/packages/core-ts/src/address/types.ts @@ -16,7 +16,8 @@ export type WarningCode = | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" - | "INVALID_DESTINATION"; + | "INVALID_DESTINATION" + | "MISSING_REQUIRED_MEMO"; export type Warning = | { @@ -51,9 +52,15 @@ export type Warning = | "NON_CANONICAL_ROUTING_ID" | "INVALID_DESTINATION" | "UNSUPPORTED_MEMO_TYPE" + | "MISSING_REQUIRED_MEMO" >; severity: "info" | "warn" | "error"; message: string; + } + | { + code: "MISSING_REQUIRED_MEMO"; + severity: "error"; + message: string; }; export type Address = diff --git a/packages/core-ts/src/index.ts b/packages/core-ts/src/index.ts index 27336212..9764b895 100644 --- a/packages/core-ts/src/index.ts +++ b/packages/core-ts/src/index.ts @@ -9,4 +9,5 @@ export * from "./routing/extract"; export * from "./routing/extractFromTx"; export * from "./routing/extractFromURI"; // ← NEW export * from "./routing/types"; -export * from "./routing/memo"; \ No newline at end of file +export * from "./routing/memo"; +export * from "./routing/memoRequirement"; diff --git a/packages/core-ts/src/routing/memoRequirement.ts b/packages/core-ts/src/routing/memoRequirement.ts new file mode 100644 index 00000000..0d7b9b26 --- /dev/null +++ b/packages/core-ts/src/routing/memoRequirement.ts @@ -0,0 +1,64 @@ +import type { MemoRequirementFetcher, RoutingInput, RoutingResult } from "./types"; +import { extractRouting } from "./extract"; + +const DEFAULT_HORIZON_URL = "https://horizon.stellar.org"; + +export type MemoRequirement = { + required: boolean; +}; + +function decodeDataValue(value: unknown): boolean { + if (value === "1" || value === 1 || value === true) return true; + if (typeof value !== "string") return false; + try { + return atob(value) === "1"; + } catch { + return false; + } +} + +/** Fetches the SEP-0029 memo requirement from a Horizon account response. */ +export async function fetchMemoRequirement( + baseAccount: string, + fetchImpl: typeof fetch = fetch, + horizonUrl = DEFAULT_HORIZON_URL +): Promise { + const response = await fetchImpl( + `${horizonUrl.replace(/\/$/, "")}/accounts/${encodeURIComponent(baseAccount)}` + ); + if (!response.ok) return false; + + const account = (await response.json()) as { data?: Record }; + return decodeDataValue(account.data?.["config.memo_required"]) || + decodeDataValue(account.data?.["config.requiring_memo"]); +} + +/** + * Performs normal routing extraction and optionally checks SEP-0029 account data. + * A failed network lookup is treated as unknown and does not block parsing. + */ +export async function extractRoutingAsync( + input: RoutingInput, + requirementFetcher: MemoRequirementFetcher = fetchMemoRequirement +): Promise { + const result = extractRouting(input); + if (!result.destinationBaseAccount || result.routingId !== null || result.destinationError) { + return result; + } + + try { + if (await requirementFetcher(result.destinationBaseAccount)) { + result.warnings = [ + ...result.warnings, + { + code: "MISSING_REQUIRED_MEMO", + severity: "error", + message: "Destination account requires a memo, but no routing ID was provided.", + }, + ]; + } + } catch { + // Network/configuration failures must not change the synchronous result. + } + return result; +} diff --git a/packages/core-ts/src/routing/types.ts b/packages/core-ts/src/routing/types.ts index 72e1be3c..a4cf847e 100644 --- a/packages/core-ts/src/routing/types.ts +++ b/packages/core-ts/src/routing/types.ts @@ -28,6 +28,8 @@ export type RoutingResult = { }; }; +export type MemoRequirementFetcher = (baseAccount: string) => Promise; + export function routingIdAsBigInt( routingId: string | bigint | null ): bigint | null { @@ -36,4 +38,4 @@ export function routingIdAsBigInt( } return typeof routingId === "bigint" ? routingId : BigInt(routingId); -} \ No newline at end of file +} diff --git a/packages/spec/schema.json b/packages/spec/schema.json index 86d5f006..972581b9 100644 --- a/packages/spec/schema.json +++ b/packages/spec/schema.json @@ -66,7 +66,8 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT" + "MEMO_ID_INVALID_FORMAT", "MISSING_REQUIRED_MEMO", + "INVALID_STRKEY" ] }, "severity": { "type": "string", "enum": ["info", "warn", "error"] }, diff --git a/spec/schema.json b/spec/schema.json index 86d5f006..972581b9 100644 --- a/spec/schema.json +++ b/spec/schema.json @@ -66,7 +66,8 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT" + "MEMO_ID_INVALID_FORMAT", "MISSING_REQUIRED_MEMO", + "INVALID_STRKEY" ] }, "severity": { "type": "string", "enum": ["info", "warn", "error"] },